feat: enhance WebSocket service with heartbeat and reconnection logic; update environment configuration for development
This commit is contained in:
commit
d9b80e9719
@ -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
440
Canvas绘制技术详解.md
Normal 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
600
Meta2D引擎作用详解.md
Normal 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不是画笔,而是整个画室的管理系统!**
|
646
WebSocket增强服务技术设计文档.md
Normal file
646
WebSocket增强服务技术设计文档.md
Normal 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. **里氏替换原则**:增强版本完全可以替换原版本
|
||||
|
||||
这个实现展示了如何在保持向后兼容的同时,提供企业级的功能增强,是一个很好的渐进式增强的例子。
|
@ -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": {
|
||||
|
@ -1,77 +1,160 @@
|
||||
import { KeydownType, type Options } from '@meta2d/core';
|
||||
import { invert } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* 地图点位类型枚举
|
||||
* 定义了场景编辑器中所有支持的点位类型
|
||||
* 数值1-9为小点位,10以上为大点位(有特殊图标)
|
||||
*/
|
||||
//#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',
|
||||
};
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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" />
|
||||
|
@ -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 ?? {};
|
||||
|
@ -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) {
|
||||
|
@ -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 };
|
||||
|
@ -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,
|
||||
|
2013
场景编辑器组件详细分析.md
Normal file
2013
场景编辑器组件详细分析.md
Normal file
File diff suppressed because it is too large
Load Diff
632
机器人运动监控组件详细分析.md
Normal file
632
机器人运动监控组件详细分析.md
Normal 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连接复用、状态缓存机制和坐标转换优化等解决方案,可以有效解决这些问题,确保多页面间机器人位置的一致性。
|
677
编辑器服务核心架构分析.md
Normal file
677
编辑器服务核心架构分析.md
Normal 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
165
自动同步仓库.ps1
Normal 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")
|
Loading…
x
Reference in New Issue
Block a user