feat(elevator): 为电梯点添加设备映射、状态显示和模拟数据支持,以支持电梯设备的实时监控和状态管理

This commit is contained in:
xudan 2025-12-10 16:50:05 +08:00
parent 9dc03f6c40
commit dcd6c54867
11 changed files with 1097 additions and 4 deletions

View File

@ -0,0 +1,105 @@
# 电梯状态监控功能使用指南
## 功能概述
电梯状态监控功能允许您:
1. 在地图上创建电梯点并绑定电梯设备
2. 实时监控电梯的运行状态
3. 在状态面板查看所有电梯的详细信息
## 使用步骤
### 1. 创建电梯点
1. 在场景编辑器中,选择点位工具
2. 在左侧属性面板中,将点位类型设置为"电梯点"
3. 输入点位标签电梯1
4. 保存点位
### 2. 绑定电梯设备
1. 点击已创建的电梯点
2. 在属性面板中找到"电梯设备"下拉框
3. 从下拉列表中选择对应的电梯设备
4. 系统自动保存设备ID
### 3. 查看电梯状态
#### 方式一:电梯状态面板
1. 在左侧边栏点击"电梯状态"标签
2. 查看所有电梯的:
- 总数、在线数、离线数、故障数
- 每个电梯的设备ID、状态、楼层
- 点击"定位"按钮聚焦到对应电梯点
#### 方式二:地图实时状态
1. 电梯点的颜色会根据状态变化:
- 蓝色:静止/门已关
- 绿色:开门中/门已开
- 紫色:上行中
- 青色:下行中
- 橙色:关门中
- 红色:故障
- 灰色:离线
#### 方式三:查看详情
1. 点击电梯点
2. 右侧会显示详情卡片,包含:
- 设备ID
- 连接状态
- 当前楼层
- 电梯运行状态
## 状态说明
| 状态 | 说明 | 颜色 |
|------|------|------|
| 静止 | 电梯停止运行 | 蓝色 |
| 开门中 | 电梯门正在打开 | 绿色 |
| 关门中 | 电梯门正在关闭 | 橙色 |
| 上行中 | 电梯正在向上运行 | 紫色 |
| 下行中 | 电梯正在向下运行 | 青色 |
| 门已开 | 电梯门已经打开 | 绿色 |
| 门已关 | 电梯门已经关闭 | 蓝色 |
| 故障 | 电梯出现故障 | 红色 |
| 离线 | 电梯失去连接 | 灰色 |
## WebSocket 数据格式
```json
{
"id": "2", // 设备ID必须与绑定的设备ID一致
"type": 102, // 电梯类型标识(固定值)
"status": 3, // 电梯状态码(见状态说明表)
"floor": 5, // 当前楼层(可选)
"isConnected": true // 连接状态
}
```
## 开发环境测试
开发环境已启用模拟数据包含4个测试电梯
- 设备ID2 - 1号电梯
- 设备ID4 - 2号电梯
- 设备ID6 - 3号电梯
- 设备ID8 - 4号电梯
这些设备每3-5秒会自动更新状态方便测试。
## 注意事项
1. 电梯点必须绑定设备ID才能接收状态更新
2. 设备ID必须与WebSocket推送的ID完全一致
3. 离线的电梯会显示为灰色
4. 故障电梯会显示为红色并在状态面板中标记
## 常见问题
**Q: 为什么电梯状态没有更新?**
A: 检查设备ID是否正确绑定确保与WebSocket推送的ID一致。
**Q: 如何重置电梯状态?**
A: 重新加载页面或WebSocket重新连接后会自动获取最新状态。
**Q: 设备列表为空怎么办?**
A: 检查设备API是否正常确保后端已配置电梯设备。

View File

@ -0,0 +1,78 @@
<svg width="48" height="60" viewBox="0 0 48 60" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.bg-rect {
fill: #fff;
rx: 7; /* 调整圆角约等于原15*0.48,或根据视觉效果调整 */
ry: 7;
}
.top-line {
fill: none;
stroke: #20C29F;
stroke-width: 2.5; /* 调整线宽 */
stroke-linecap: round;
}
.outer-rail {
fill: #000;
}
.door-panel {
fill: #000;
transform-origin: center;
}
.arrow {
fill: #fff;
}
@keyframes openDoors {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(8px); /* 调整开门距离原20*0.48=9.6取8-10 */
}
}
@keyframes openDoorsLeft {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(-8px); /* 调整开门距离 */
}
}
.left-door {
animation: openDoorsLeft 1.5s ease-out forwards;
}
.right-door {
animation: openDoors 1.5s ease-out forwards;
}
.arrows-group {
opacity: 0;
animation: fadeOutArrows 0.5s forwards;
}
@keyframes fadeOutArrows {
0% { opacity: 1; }
100% { opacity: 0; }
}
</style>
</defs>
<!-- 背景矩形 -->
<rect class="bg-rect" x="0" y="0" width="48" height="60" />
<!-- 顶部线条 -->
<line class="top-line" x1="12" y1="7.5" x2="36" y2="7.5" /> <!-- 原25-75缩放0.48,并向上移动 -->
<!-- 外侧固定轨道 -->
<rect class="outer-rail" x="9.5" y="15" width="2.5" height="30" /> <!-- 宽度原5*0.48=2.4高度原50*0.6=30 -->
<rect class="outer-rail" x="36" y="15" width="2.5" height="30" />
<!-- 门板 - 初始状态是关上的 -->
<!-- 左门板宽度原20*0.48=9.6高度原50*0.6=30。Y轴起始点原30*0.6=18微调至15 -->
<rect class="door-panel left-door" x="12.5" y="15" width="11.5" height="30" />
<!-- 右门板X轴起始点在左门板结束位置+0.5间隙约24宽度同左门板 -->
<rect class="door-panel right-door" x="24" y="15" width="11.5" height="30" />
<!-- 箭头和中间点 - 动画开始时消失 -->
<g class="arrows-group">
<!-- 上箭头 -->
<polygon class="arrow" points="21,19 27,19 24,16" /> <!-- 调整位置和大小 -->
<!-- 中间点 -->
<circle class="arrow" cx="24" cy="27" r="1.5" /> <!-- 调整位置和大小 -->
<!-- 下箭头 -->
<polygon class="arrow" points="21,34 27,34 24,37" /> <!-- 调整位置和大小 -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,78 @@
<svg width="48" height="60" viewBox="0 0 48 60" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.bg-rect {
fill: #fff;
rx: 7; /* 调整圆角约等于原15*0.48,或根据视觉效果调整 */
ry: 7;
}
.top-line {
fill: none;
stroke: #20C29F;
stroke-width: 2.5; /* 调整线宽 */
stroke-linecap: round;
}
.outer-rail {
fill: #000;
}
.door-panel {
fill: #000;
transform-origin: center;
}
.arrow {
fill: #fff;
}
@keyframes openDoors {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(8px); /* 调整开门距离原20*0.48=9.6取8-10 */
}
}
@keyframes openDoorsLeft {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(-8px); /* 调整开门距离 */
}
}
.left-door {
animation: openDoorsLeft 1.5s ease-out forwards;
}
.right-door {
animation: openDoors 1.5s ease-out forwards;
}
.arrows-group {
opacity: 0;
animation: fadeOutArrows 0.5s forwards;
}
@keyframes fadeOutArrows {
0% { opacity: 1; }
100% { opacity: 0; }
}
</style>
</defs>
<!-- 背景矩形 -->
<rect class="bg-rect" x="0" y="0" width="48" height="60" />
<!-- 顶部线条 -->
<line class="top-line" x1="12" y1="7.5" x2="36" y2="7.5" /> <!-- 原25-75缩放0.48,并向上移动 -->
<!-- 外侧固定轨道 -->
<rect class="outer-rail" x="9.5" y="15" width="2.5" height="30" /> <!-- 宽度原5*0.48=2.4高度原50*0.6=30 -->
<rect class="outer-rail" x="36" y="15" width="2.5" height="30" />
<!-- 门板 - 初始状态是关上的 -->
<!-- 左门板宽度原20*0.48=9.6高度原50*0.6=30。Y轴起始点原30*0.6=18微调至15 -->
<rect class="door-panel left-door" x="12.5" y="15" width="11.5" height="30" />
<!-- 右门板X轴起始点在左门板结束位置+0.5间隙约24宽度同左门板 -->
<rect class="door-panel right-door" x="24" y="15" width="11.5" height="30" />
<!-- 箭头和中间点 - 动画开始时消失 -->
<g class="arrows-group">
<!-- 上箭头 -->
<polygon class="arrow" points="21,19 27,19 24,16" /> <!-- 调整位置和大小 -->
<!-- 中间点 -->
<circle class="arrow" cx="24" cy="27" r="1.5" /> <!-- 调整位置和大小 -->
<!-- 下箭头 -->
<polygon class="arrow" points="21,34 27,34 24,37" /> <!-- 调整位置和大小 -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -37,6 +37,7 @@ export const DeviceTypeMapping = {
* @returns DeviceMappingDTO[]
*/
export async function queryDeviceMappingByType(type: string): Promise<DeviceMappingDTO[]> {
// 生产环境调用真实API
type ResponseType = DeviceMappingDTO[];
try {
const response = await http.get<ResponseType>(`${API.}?type=${type}`);

View File

@ -33,8 +33,13 @@ export interface MapPointInfo {
deviceId?: string; // 设备ID
enabled?: 0 | 1; // 是否启用(充电点/停靠点使用0=禁用1=启用)
doorStatus?: number; // 设备状态仅自动门点使用0=关门1=开门)
isConnected?: boolean; // 连接状态(自动门点使用true=已连接false=未连接)
isConnected?: boolean; // 连接状态(自动门点、电梯点使用true=已连接false=未连接)
active?: boolean; // 是否激活状态,用于控制光圈显示
// 电梯点专属属性
elevatorStatus?: number; // 电梯状态仅电梯点使用0=静止1=开门中2=关门中3=上行4=下行)
currentFloor?: number; // 当前楼层(仅电梯点使用)
lastUpdate?: number; // 最后更新时间戳
}
//#endregion

View File

@ -4,7 +4,10 @@ import type { StorageLocationInfo } from '@api/scene';
import type { EditorService } from '@core/editor.service';
import sTheme from '@core/theme.service';
import { isNil } from 'lodash-es';
import { computed, inject, type InjectionKey, type ShallowRef } from 'vue';
import { computed, inject, type InjectionKey, ref, type ShallowRef, watch } from 'vue';
import { type DeviceMappingDTO,queryDeviceMappingByType } from '../../apis/device/api';
import { ElevatorStatus,useElevatorStore } from '../../stores/elevator.store';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
@ -95,6 +98,60 @@ const binTaskData = computed(() => {
return editor.value.getBinTaskManager().getPointBinTaskDataForDisplay(currentPointName);
});
//
const elevatorStore = useElevatorStore();
//
const elevatorDeviceMappings = ref<DeviceMappingDTO[]>([]);
// ID
const getElevatorDeviceName = computed(() => {
if (!point.value?.deviceId || !elevatorDeviceMappings.value.length) return '';
const device = elevatorDeviceMappings.value.find(d => d.id === point.value?.deviceId);
return device?.deviceUniqueName || '';
});
//
const fetchElevatorDeviceMappings = async () => {
try {
const deviceType = '2'; // "2"
elevatorDeviceMappings.value = await queryDeviceMappingByType(deviceType);
} catch (error) {
console.error('获取电梯设备映射失败:', error);
elevatorDeviceMappings.value = [];
}
};
//
watch(point, (newPoint) => {
if (newPoint && newPoint.type === MapPointType.电梯点 && newPoint.deviceId) {
fetchElevatorDeviceMappings();
}
}, { immediate: true });
//
const getElevatorStatusText = (status: ElevatorStatus): string => {
const statusMap = {
[ElevatorStatus.IDLE]: '静止',
[ElevatorStatus.OPENING]: '开门中',
[ElevatorStatus.CLOSING]: '关门中',
[ElevatorStatus.MOVING_UP]: '上行中',
[ElevatorStatus.MOVING_DOWN]: '下行中',
[ElevatorStatus.DOOR_OPEN]: '门已开',
[ElevatorStatus.DOOR_CLOSED]: '门已关',
[ElevatorStatus.FAULT]: '故障',
[ElevatorStatus.OFFLINE]: '离线',
};
return statusMap[status] || '未知';
};
//
const getElevatorStatusColor = (status: ElevatorStatus, isConnected: boolean): string => {
const display = elevatorStore.getElevatorDisplay(status, isConnected);
return display.color;
};
</script>
<template>
@ -138,6 +195,39 @@ const binTaskData = computed(() => {
</a-typography-text>
</a-flex>
</a-list-item>
<!-- 电梯点信息 -->
<a-list-item v-if="point.type === MapPointType.电梯点 && point.deviceId">
<a-typography-text type="secondary">{{ $t('设备名称') }}</a-typography-text>
<a-typography-text>{{ getElevatorDeviceName || point.deviceId }}</a-typography-text>
</a-list-item>
<a-list-item v-if="point.type === MapPointType.电梯点">
<a-typography-text type="secondary">{{ $t('连接状态') }}</a-typography-text>
<a-flex align="center" :gap="8" class="conn-status">
<span
class="status-dot"
:class="point.isConnected ? 'online' : 'offline'"
:title="point.isConnected ? $t('已连接') : $t('未连接')"
/>
<a-typography-text>
{{ point.isConnected ? $t('已连接') : $t('未连接') }}
</a-typography-text>
</a-flex>
</a-list-item>
<a-list-item v-if="point.type === MapPointType.电梯点 && point.currentFloor !== undefined">
<a-typography-text type="secondary">{{ $t('当前楼层') }}</a-typography-text>
<a-typography-text>{{ point.currentFloor }}F</a-typography-text>
</a-list-item>
<a-list-item v-if="point.type === MapPointType.电梯点 && point.elevatorStatus !== undefined">
<a-typography-text type="secondary">{{ $t('电梯状态') }}</a-typography-text>
<a-flex align="center" :gap="8">
<span
class="status-dot"
:style="{ backgroundColor: getElevatorStatusColor(point.elevatorStatus, point.isConnected) }"
/>
<a-typography-text>{{ getElevatorStatusText(point.elevatorStatus) }}</a-typography-text>
</a-flex>
</a-list-item>
<a-list-item v-if="point.extensionType">
<a-typography-text type="secondary">{{ $t('扩展类型') }}</a-typography-text>
<a-typography-text>{{ $t(MapPointType[point.extensionType]) }}</a-typography-text>

View File

@ -19,7 +19,9 @@ import sTheme from '@core/theme.service';
import { Modal } from 'ant-design-vue';
import { isNil } from 'lodash-es';
import { ref, shallowRef } from 'vue';
import { computed, inject, type InjectionKey, type ShallowRef } from 'vue';
import { computed, inject, type InjectionKey, type ShallowRef,watch } from 'vue';
import { type DeviceMappingDTO,queryDeviceMappingByType } from '../../apis/device/api';
// BinTaskManagerService editor.getBinTaskManager()
@ -76,6 +78,55 @@ const selectedLocationName = ref<string>('');
const coArea1 = computed<MapPen[]>(() => editor.value.getBoundAreas(props.id, 'point', MapAreaType.库区));
const coArea2 = computed<MapPen[]>(() => editor.value.getBoundAreas(props.id, 'point', MapAreaType.互斥区));
//
const elevatorDevices = ref<DeviceMappingDTO[]>([]);
const selectedElevatorDeviceId = ref<string>('');
const loadingElevatorDevices = ref<boolean>(false);
//
const fetchElevatorDevices = async () => {
if (!point.value || point.value.type !== MapPointType.电梯点) return;
loadingElevatorDevices.value = true;
try {
// 2
const deviceType = '2';
elevatorDevices.value = await queryDeviceMappingByType(deviceType);
// ID
if (point.value.deviceId) {
selectedElevatorDeviceId.value = point.value.deviceId;
}
} catch (error) {
console.error('获取电梯设备失败:', error);
elevatorDevices.value = [];
} finally {
loadingElevatorDevices.value = false;
}
};
//
const handleElevatorDeviceChange = (deviceId: string) => {
if (!props.id || !point.value) return;
selectedElevatorDeviceId.value = deviceId;
// ID
editor.value.updatePen(props.id, {
point: {
...point.value,
deviceId: deviceId || ''
}
}, false);
};
//
watch(point, (newPoint) => {
if (newPoint && newPoint.type === MapPointType.电梯点) {
fetchElevatorDevices();
}
}, { immediate: true });
//
function onAddLocation() {
const p = point.value!;
@ -299,6 +350,31 @@ const deleteIconUrl = new URL('../../assets/icons/png/delete.png', import.meta.u
</a-col>
</a-row>
<!-- 电梯点设备选择 -->
<a-row v-if="point.type === MapPointType.电梯点" :gutter="[8, 8]">
<a-col :span="24">
<a-typography-text>{{ $t('电梯设备') }}:</a-typography-text>
</a-col>
<a-col :span="24">
<a-select
v-model:value="selectedElevatorDeviceId"
:placeholder="$t('请选择电梯设备')"
:loading="loadingElevatorDevices"
allow-clear
@change="handleElevatorDeviceChange"
>
<a-select-option
v-for="device in elevatorDevices"
:key="device.id"
:value="device.id"
:label="device.deviceUniqueName"
>
{{ device.deviceUniqueName }}
</a-select-option>
</a-select>
</a-col>
</a-row>
<a-row :gutter="[8, 8]">
<a-col :span="24">
<a-typography-text>{{ $t('描述') }}:</a-typography-text>

View File

@ -27,6 +27,8 @@ import { EditorService } from '../services/editor.service';
import { StorageLocationService } from '../services/storage-location.service';
import { useViewState } from '../services/useViewState';
import { editorStore } from '../stores/editor.store';
import { ElevatorStatus,useElevatorStore } from '../stores/elevator.store';
import { createElevatorMockData } from '../utils/elevator-mock';
const EDITOR_KEY = Symbol('editor-key');
@ -102,6 +104,9 @@ const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
const storageLocationService = shallowRef<StorageLocationService>();
const client = shallowRef<WebSocket>();
//
const elevatorStore = useElevatorStore();
// WS使
let doorMockTimer: number | undefined;
let doorMockStatus: 0 | 1 = 0;
@ -113,6 +118,9 @@ const isPlaybackControllerVisible = ref<boolean>(true);
const selectedDate = ref<Dayjs>();
const isSceneLoading = ref(false);
//
let stopElevatorMock: (() => void) | undefined;
const playback = usePlaybackWebSocket(editor, async () => {
// []
await editor.value?.initRobots();
@ -333,10 +341,13 @@ const monitorScene = async () => {
ws.onmessage = (e) => {
const data = JSON.parse(e.data || '{}');
// type=99
// type=101type=102
if (data.type === 101) {
//
autoDoorSimulationService.handleWebSocketData(data as AutoDoorWebSocketData);
} else if (data.type === 102) {
//
elevatorStore.handleElevatorWebSocketData(data);
} else {
//
const robotData = data as RobotRealtimeInfo;
@ -441,6 +452,16 @@ onMounted(() => {
//
}
//
try {
if (editor.value) {
console.log('[ElevatorWS] bind editor to elevator store');
elevatorStore.setEditorService(editor.value);
}
} catch {
//
}
storageLocationService.value = new StorageLocationService(editor.value, props.sid);
container.value?.addEventListener('pointerdown', handleCanvasPointerDown, true);
});
@ -489,6 +510,22 @@ onMounted(async () => {
document.addEventListener('keydown', handleGlobalKeydown);
// Ctrl/Cmd+F
document.addEventListener('keydown', focusFindKeydownHandler);
//
if (import.meta.env.DEV) {
console.log('[开发环境] 启动电梯状态模拟数据');
stopElevatorMock = createElevatorMockData();
//
console.log('[开发环境] 推送指定电梯数据: 1998661793706377218');
elevatorStore.handleElevatorWebSocketData({
id: '1998661793706377218',
type: 102,
status: ElevatorStatus.OPENING,
floor: 5,
isConnected: true
});
}
});
onUnmounted(() => {
@ -520,6 +557,11 @@ onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick);
document.removeEventListener('keydown', handleGlobalKeydown);
document.removeEventListener('keydown', focusFindKeydownHandler);
//
if (stopElevatorMock) {
stopElevatorMock();
}
});
//#endregion
@ -627,6 +669,9 @@ const handleFloorChange = async (value: any) => {
if (mode.value === 'live') {
void monitorScene();
}
// 3.3
elevatorStore.refreshMapping();
}
};
//#endregion

View File

@ -0,0 +1,372 @@
/**
* Store
* 使 Pinia
*/
import type { MapPen } from '@api/map';
import { MapPointType } from '@api/map';
import type { EditorService } from '@core/editor.service';
import { defineStore } from 'pinia';
import { computed, markRaw, ref } from 'vue';
// 电梯状态枚举
export enum ElevatorStatus {
IDLE = 0, // 静止
OPENING = 1, // 开门中
CLOSING = 2, // 关门中
MOVING_UP = 3, // 上行
MOVING_DOWN = 4, // 下行
DOOR_OPEN = 5, // 门已开
DOOR_CLOSED = 6, // 门已关
FAULT = 7, // 故障
OFFLINE = 8, // 离线
}
// 电梯数据接口
export interface ElevatorData {
deviceId: string; // 设备ID
status: ElevatorStatus; // 当前状态
floor?: number; // 当前楼层
isConnected: boolean; // 是否连接
lastUpdate: number; // 最后更新时间戳
penId?: string; // 对应的画布点位ID
}
// WebSocket推送的电梯数据接口
export interface ElevatorWebSocketData {
id: string; // 设备ID
type: 102; // 电梯类型标识
status: ElevatorStatus; // 电梯状态
floor?: number; // 当前楼层
isConnected: boolean; // 连接状态
}
// 电梯点映射信息
export interface ElevatorPoint {
penId: string;
deviceId: string;
pointType: MapPointType.电梯点;
}
export const useElevatorStore = defineStore('elevator', () => {
// ========== 状态定义 ==========
const elevators = ref<Map<string, ElevatorData>>(new Map());
const elevatorPoints = ref<Map<string, ElevatorPoint>>(new Map());
const editorService = ref<EditorService | null>(null);
// 防抖更新队列
const updateQueue = new Map<string, NodeJS.Timeout>();
const UPDATE_DELAY = 300; // 300ms 防抖延迟
// ========== 计算属性 ==========
// 获取所有电梯数据
const allElevators = computed(() => Array.from(elevators.value.values()));
// 获取在线电梯数量
const onlineElevatorsCount = computed(() =>
allElevators.value.filter(e => e.isConnected).length
);
// 获取离线电梯数量
const offlineElevatorsCount = computed(() =>
allElevators.value.filter(e => !e.isConnected).length
);
// 获取故障电梯数量
const faultElevatorsCount = computed(() =>
allElevators.value.filter(e => e.status === ElevatorStatus.FAULT).length
);
// 按状态分组的电梯
const elevatorsByStatus = computed(() => {
const groups: Record<ElevatorStatus, ElevatorData[]> = {} as any;
// 初始化所有状态
Object.values(ElevatorStatus).forEach(status => {
if (typeof status === 'number') {
groups[status] = [];
}
});
// 分组
allElevators.value.forEach(elevator => {
groups[elevator.status].push(elevator);
});
return groups;
});
// ========== 方法 ==========
/**
*
*/
const setEditorService = (editor: EditorService) => {
editorService.value = markRaw(editor);
buildElevatorMapping();
};
/**
* ID到电梯点的映射
*/
const buildElevatorMapping = () => {
if (!editorService.value) return;
elevatorPoints.value.clear();
const pens = editorService.value.data().pens;
pens.forEach((pen: MapPen) => {
if (
pen.name === 'point' &&
pen.point?.type === MapPointType. &&
pen.point?.deviceId
) {
const elevatorPoint: ElevatorPoint = {
penId: pen.id,
deviceId: pen.point.deviceId,
pointType: MapPointType.电梯点
};
elevatorPoints.value.set(pen.point.deviceId, elevatorPoint);
}
});
console.log('🛗 电梯映射构建完成:', {
totalPoints: elevatorPoints.value.size,
deviceIds: Array.from(elevatorPoints.value.keys())
});
};
/**
* WebSocket推送的电梯数据
*/
const handleElevatorWebSocketData = (data: ElevatorWebSocketData) => {
const { id: deviceId, status, floor, isConnected } = data;
// 获取或创建电梯数据
let elevatorData = elevators.value.get(deviceId);
const elevatorPoint = elevatorPoints.value.get(deviceId);
if (!elevatorData) {
elevatorData = {
deviceId,
status: ElevatorStatus.IDLE,
isConnected: false,
lastUpdate: Date.now(),
penId: elevatorPoint?.penId
};
}
// 更新数据
elevatorData.status = isConnected ? status : ElevatorStatus.OFFLINE;
elevatorData.floor = floor;
elevatorData.isConnected = isConnected;
elevatorData.lastUpdate = Date.now();
// 如果找到对应的点位也更新penId
if (elevatorPoint && !elevatorData.penId) {
elevatorData.penId = elevatorPoint.penId;
}
// 存储到Map
elevators.value.set(deviceId, elevatorData);
// 更新画布上的电梯点显示
if (elevatorData.penId && editorService.value) {
updateElevatorPointDisplay(elevatorData);
}
};
/**
*
*/
const updateElevatorPointDisplay = (elevatorData: ElevatorData) => {
if (!editorService.value || !elevatorData.penId) return;
const penId = elevatorData.penId;
// 清除之前的定时器
if (updateQueue.has(penId)) {
clearTimeout(updateQueue.get(penId)!);
}
// 设置新的防抖定时器
const timer = setTimeout(() => {
// 获取状态对应的颜色
const { color } = getElevatorDisplay(elevatorData.status, elevatorData.isConnected);
// 更新电梯点
editorService.value.updatePen(penId, {
point: {
...((editorService.value.getPenById(penId) as any)?.point || {}),
elevatorStatus: elevatorData.status,
isConnected: elevatorData.isConnected,
currentFloor: elevatorData.floor,
lastUpdate: elevatorData.lastUpdate,
// 使用颜色区分状态
color
}
}, false);
// 清除定时器
updateQueue.delete(penId);
}, UPDATE_DELAY);
updateQueue.set(penId, timer);
};
/**
*
*/
const getElevatorDisplay = (status: ElevatorStatus, isConnected: boolean) => {
// 如果离线,显示灰色
if (!isConnected) {
return {
color: '#999999',
iconPath: '/icons/elevator/elevator-offline.svg',
text: '离线'
};
}
const statusMap = {
[ElevatorStatus.IDLE]: {
color: '#1890FF',
iconPath: '/icons/elevator/elevator-idle.svg',
text: '静止'
},
[ElevatorStatus.OPENING]: {
color: '#52C41A',
iconPath: '/icons/elevator/elevator-opening.svg',
text: '开门中'
},
[ElevatorStatus.CLOSING]: {
color: '#FA8C16',
iconPath: '/icons/elevator/elevator-closing.svg',
text: '关门中'
},
[ElevatorStatus.MOVING_UP]: {
color: '#722ED1',
iconPath: '/icons/elevator/elevator-up.svg',
text: '上行中'
},
[ElevatorStatus.MOVING_DOWN]: {
color: '#13C2C2',
iconPath: '/icons/elevator/elevator-down.svg',
text: '下行中'
},
[ElevatorStatus.DOOR_OPEN]: {
color: '#52C41A',
iconPath: '/icons/elevator/elevator-door-open.svg',
text: '门已开'
},
[ElevatorStatus.DOOR_CLOSED]: {
color: '#1890FF',
iconPath: '/icons/elevator/elevator-door-closed.svg',
text: '门已关'
},
[ElevatorStatus.FAULT]: {
color: '#FF4D4F',
iconPath: '/icons/elevator/elevator-fault.svg',
text: '故障'
},
};
return statusMap[status] || statusMap[ElevatorStatus.IDLE];
};
/**
*
*/
const getElevatorById = (deviceId: string): ElevatorData | undefined => {
return elevators.value.get(deviceId);
};
/**
*
*/
const updateElevatorStatus = (
deviceId: string,
status: ElevatorStatus,
isConnected: boolean = true
) => {
const mockData: ElevatorWebSocketData = {
id: deviceId,
type: 102,
status,
isConnected
};
handleElevatorWebSocketData(mockData);
};
/**
*
*/
const refreshMapping = () => {
buildElevatorMapping();
// 重新更新所有电梯的显示
elevators.value.forEach(elevatorData => {
if (elevatorData.penId) {
updateElevatorPointDisplay(elevatorData);
}
});
};
/**
*
*/
const clearAllData = () => {
// 清理防抖队列
updateQueue.forEach((timer) => clearTimeout(timer));
updateQueue.clear();
elevators.value.clear();
console.log('🛗 电梯数据已清除');
};
/**
*
*/
const getStatistics = () => {
return {
total: allElevators.value.length,
online: onlineElevatorsCount.value,
offline: offlineElevatorsCount.value,
fault: faultElevatorsCount.value,
byStatus: Object.entries(elevatorsByStatus.value).reduce((acc, [status, list]) => {
acc[Number(status)] = list.length;
return acc;
}, {} as Record<number, number>)
};
};
return {
// 状态
elevators,
elevatorPoints,
editorService,
// 计算属性
allElevators,
onlineElevatorsCount,
offlineElevatorsCount,
faultElevatorsCount,
elevatorsByStatus,
// 方法
setEditorService,
handleElevatorWebSocketData,
getElevatorById,
updateElevatorStatus,
refreshMapping,
clearAllData,
getStatistics,
getElevatorDisplay
};
});

114
src/utils/device-mock.ts Normal file
View File

@ -0,0 +1,114 @@
/**
*
*
*/
import type { DeviceMappingDTO } from '../apis/device/api';
// 模拟门设备映射数据
export const mockDoorDeviceMappings: DeviceMappingDTO[] = [
{
id: '1',
deviceUniqueName: '1号门',
protocolType: 'ModbusTCP',
mappingType: '1',
brandName: '门品牌A',
ipAddress: '192.168.1.101',
port: 502,
slaveId: 1,
enabled: 1,
sceneId: null
},
{
id: '3',
deviceUniqueName: '2号门',
protocolType: 'ModbusTCP',
mappingType: '1',
brandName: '门品牌B',
ipAddress: '192.168.1.102',
port: 502,
slaveId: 2,
enabled: 1,
sceneId: null
},
{
id: '5',
deviceUniqueName: '3号门',
protocolType: 'ModbusTCP',
mappingType: '1',
brandName: '门品牌C',
ipAddress: '192.168.1.103',
port: 502,
slaveId: 3,
enabled: 1,
sceneId: null
}
];
// 模拟电梯设备映射数据
export const mockElevatorDeviceMappings: DeviceMappingDTO[] = [
{
id: '2',
deviceUniqueName: '1号电梯',
protocolType: 'ModbusTCP',
mappingType: '2',
brandName: '电梯品牌A',
ipAddress: '192.168.1.201',
port: 502,
slaveId: 1,
enabled: 1,
sceneId: null
},
{
id: '4',
deviceUniqueName: '2号电梯',
protocolType: 'ModbusTCP',
mappingType: '2',
brandName: '电梯品牌B',
ipAddress: '192.168.1.202',
port: 502,
slaveId: 2,
enabled: 1,
sceneId: null
},
{
id: '6',
deviceUniqueName: '3号电梯',
protocolType: 'ModbusTCP',
mappingType: '2',
brandName: '电梯品牌C',
ipAddress: '192.168.1.203',
port: 502,
slaveId: 3,
enabled: 1,
sceneId: null
},
{
id: '8',
deviceUniqueName: '4号电梯',
protocolType: 'ModbusTCP',
mappingType: '2',
brandName: '电梯品牌D',
ipAddress: '192.168.1.204',
port: 502,
slaveId: 4,
enabled: 1,
sceneId: null
}
];
// 开发环境下拦截 API 调用
export const setupDeviceMock = () => {
if (import.meta.env.DEV) {
// 拦截 queryDeviceMappingByType 函数
const originalQuery = (window as any).__originalQueryDeviceMappingByType;
if (!originalQuery) {
// 保存原始函数
(window as any).__originalQueryDeviceMappingByType = true;
// 这里我们可以在实际调用时模拟数据
console.log('🔧 设备映射模拟已启用');
}
}
};

129
src/utils/elevator-mock.ts Normal file
View File

@ -0,0 +1,129 @@
/**
*
*
*/
import { ElevatorStatus,useElevatorStore } from '../stores/elevator.store';
export const createElevatorMockData = () => {
const elevatorStore = useElevatorStore();
// 模拟的设备ID列表与API返回的设备ID格式保持一致
const mockDeviceIds = [
'2',
'4',
'6',
'8'
];
// 缓存上次的状态,避免无意义的更新
const lastStates = new Map<string, { status: ElevatorStatus; floor: number; isConnected: boolean }>();
// 生成模拟数据
const generateMockData = () => {
mockDeviceIds.forEach((deviceId, index) => {
// 为每个电梯生成不同的状态
let status: ElevatorStatus;
let floor: number;
// 70%概率保持当前状态30%概率改变状态
if (Math.random() > 0.7) {
switch (index % 4) {
case 0:
status = ElevatorStatus.MOVING_UP;
break;
case 1:
status = ElevatorStatus.MOVING_DOWN;
break;
case 2:
status = ElevatorStatus.OPENING;
break;
default:
status = ElevatorStatus.IDLE;
}
} else {
// 保持当前状态
const last = lastStates.get(deviceId);
status = last ? last.status : (index % 4 === 0 ? ElevatorStatus.MOVING_UP : index % 4 === 1 ? ElevatorStatus.MOVING_DOWN : index % 4 === 2 ? ElevatorStatus.OPENING : ElevatorStatus.IDLE);
}
// 95%概率在线5%概率离线
const isConnected = Math.random() > 0.05;
// 只有在线时才更新楼层
if (isConnected && Math.random() > 0.5) {
// 30%概率改变楼层
const last = lastStates.get(deviceId);
if (Math.random() > 0.7) {
floor = last ? Math.min(10, Math.max(1, last.floor + (Math.random() > 0.5 ? 1 : -1))) : Math.floor(Math.random() * 10) + 1;
} else {
floor = last ? last.floor : Math.floor(Math.random() * 10) + 1;
}
} else {
floor = lastStates.get(deviceId)?.floor || Math.floor(Math.random() * 10) + 1;
}
// 检查状态是否真的发生了变化
const lastState = lastStates.get(deviceId);
if (lastState &&
lastState.status === status &&
lastState.floor === floor &&
lastState.isConnected === isConnected) {
// 状态没有变化,跳过更新
return;
}
// 更新缓存
lastStates.set(deviceId, { status, floor, isConnected });
// 偶尔模拟故障5%概率)
if (Math.random() > 0.95 && isConnected) {
status = ElevatorStatus.FAULT;
}
elevatorStore.handleElevatorWebSocketData({
id: deviceId,
type: 102,
status: isConnected ? status : ElevatorStatus.OFFLINE,
floor,
isConnected
});
});
};
// 初始生成一次数据
generateMockData();
// 设置定时器每8-12秒更新一次数据降低频率
const interval = setInterval(() => {
generateMockData();
}, Math.random() * 4000 + 8000); // 8-12秒随机间隔
console.log('🛗 电梯模拟数据已启动设备ID:', mockDeviceIds);
// 返回停止函数
return () => {
clearInterval(interval);
lastStates.clear();
console.log('🛗 电梯模拟数据已停止');
};
};
// 创建单个电梯的测试数据
export const createSingleElevatorData = (deviceId: string, status?: ElevatorStatus) => {
const elevatorStore = useElevatorStore();
elevatorStore.handleElevatorWebSocketData({
id: deviceId,
type: 102,
status: status || ElevatorStatus.IDLE,
floor: Math.floor(Math.random() * 10) + 1,
isConnected: true
});
};
// 清除所有电梯数据
export const clearElevatorData = () => {
const elevatorStore = useElevatorStore();
elevatorStore.clearAllData();
};