feat: 更新库位状态监控接口,添加库位信息类型定义,增强组件以显示库位状态标签

This commit is contained in:
xudan 2025-07-18 15:52:26 +08:00
parent 107ef11106
commit 418c66c721
6 changed files with 517 additions and 15 deletions

View File

@ -14,7 +14,7 @@ const enum API {
= '/scene/monitor/:id',
= '/scene/monitor/real/:id',
= '/scene/monitor/storage/:id',
= '/ws/storage-location/:id',
}
export async function getSceneById(id: SceneInfo['id']): Promise<SceneDetail | null> {
@ -122,3 +122,41 @@ export async function monitorRealSceneById(id: SceneInfo['id']): Promise<WebSock
return null;
}
}
export async function monitorStorageLocationById(
id: SceneInfo['id'],
options?: {
interval?: number;
storage_area_id?: string;
station_name?: string;
layer_name?: string;
is_occupied?: boolean;
is_locked?: boolean;
is_disabled?: boolean;
},
): Promise<WebSocket | null> {
if (!id) return null;
try {
let url = API..replace(':id', id);
// 构建查询参数
const params = new URLSearchParams();
if (options?.interval !== undefined) params.append('interval', options.interval.toString());
if (options?.storage_area_id) params.append('storage_area_id', options.storage_area_id);
if (options?.station_name) params.append('station_name', options.station_name);
if (options?.layer_name) params.append('layer_name', options.layer_name);
if (options?.is_occupied !== undefined) params.append('is_occupied', options.is_occupied.toString());
if (options?.is_locked !== undefined) params.append('is_locked', options.is_locked.toString());
if (options?.is_disabled !== undefined) params.append('is_disabled', options.is_disabled.toString());
if (params.toString()) {
url += '?' + params.toString();
}
const socket = await ws.create(import.meta.env.ENV_STORAGE_WEBSOCKET_BASE + url);
return socket;
} catch (error) {
console.debug(error);
return null;
}
}

View File

@ -74,3 +74,85 @@ export interface StandardSceneArea {
config?: object; // 其它属性配置(可按需增加)
properties?: unknown; // 附加数据(前端不做任何处理)
}
// 库位状态相关类型定义
export interface StorageLocationInfo {
id: string; // 层ID
layer_index: number; // 层索引(从1开始)
layer_name: string; // 层名称、库位名称
operate_point_id: string; // 动作点ID
station_name: string; // 站点名称
scene_id: string; // 场景ID
storage_area_id: string; // 库区ID
area_name: string; // 库区名称
is_occupied: boolean; // 是否占用
is_locked: boolean; // 是否锁定
is_disabled: boolean; // 是否禁用
is_empty_tray: boolean; // 是否空托盘
locked_by: string | null; // 锁定者
goods_content: string; // 货物内容
goods_weight: number | null; // 货物重量(克)
goods_volume: number | null; // 货物体积(立方厘米)
goods_stored_at: string | null; // 货物存放时间
goods_retrieved_at: string | null; // 货物取出时间
last_access_at: string; // 最后访问时间
max_weight: number; // 最大承重(克)
max_volume: number; // 最大体积(立方厘米)
layer_height: number; // 层高(毫米)
tags: string; // 标签
description: string | null; // 层描述
created_at: string; // 创建时间
updated_at: string; // 更新时间
}
// WebSocket消息类型
export interface StorageLocationUpdateMessage {
type: 'storage_location_update';
scene_id: string;
timestamp: string;
message: string;
data: {
total: number;
page: number;
page_size: number;
total_pages: number;
storage_locations: StorageLocationInfo[];
};
}
export interface StorageLocationStatusChangeMessage {
type: 'storage_location_status_change';
scene_id: string;
layer_name: string;
action: string;
timestamp: string;
new_status: {
id: string;
is_occupied: boolean;
is_locked: boolean;
is_disabled: boolean;
is_empty_tray: boolean;
locked_by: string | null;
goods_content: string;
last_access_at: string;
updated_at: string;
};
}
export interface StorageLocationErrorMessage {
type: 'error';
scene_id: string;
timestamp: string;
message: string;
}
export type StorageLocationMessage =
| StorageLocationUpdateMessage
| StorageLocationStatusChangeMessage
| StorageLocationErrorMessage;
// 客户端发送消息类型
export interface StorageLocationClientMessage {
type: 'get_status';
timestamp: string;
}

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { MapAreaType, type MapPen, type MapPointInfo, MapPointType, type Rect } from '@api/map';
import type { StorageLocationInfo } from '@api/scene';
import type { EditorService } from '@core/editor.service';
import sTheme from '@core/theme.service';
import { isNil } from 'lodash-es';
@ -8,6 +9,7 @@ import { computed, inject, type InjectionKey, type ShallowRef } from 'vue';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
current?: string;
storageLocations?: StorageLocationInfo[]; //
};
const props = defineProps<Props>();
const editor = inject(props.token)!;
@ -52,6 +54,31 @@ const mapAreas = (type: MapAreaType): string => {
};
const coArea1 = computed<string>(() => mapAreas(MapAreaType.库区));
const coArea2 = computed<string>(() => mapAreas(MapAreaType.互斥区));
//
const getStorageStatusTag = (location: StorageLocationInfo) => {
const tags = [];
if (location.is_occupied) {
tags.push({ text: '已占用', color: 'error' });
} else {
tags.push({ text: '空闲', color: 'success' });
}
if (location.is_locked) {
tags.push({ text: '已锁定', color: 'warning' });
}
if (location.is_disabled) {
tags.push({ text: '已禁用', color: 'error' });
}
if (location.is_empty_tray) {
tags.push({ text: '空托盘', color: 'processing' });
}
return tags;
};
</script>
<template>
@ -123,8 +150,101 @@ const coArea2 = computed<string>(() => mapAreas(MapAreaType.互斥区));
<a-typography-text>{{ coArea2 || $t('暂无') }}</a-typography-text>
</a-flex>
</a-list-item>
<a-list-item v-if="MapPointType.动作点 === point.type && storageLocations?.length">
<a-flex :gap="8" vertical>
<a-typography-text type="secondary">{{ $t('库位状态') }}</a-typography-text>
<div class="storage-locations">
<div v-for="location in storageLocations" :key="location.id" class="storage-item">
<a-typography-text class="storage-name">{{ location.layer_name }}</a-typography-text>
<div class="storage-tags">
<div
v-for="tag in getStorageStatusTag(location)"
:key="tag.text"
:class="['storage-tag', `storage-tag-${tag.color}`]"
>
{{ tag.text }}
</div>
</div>
</div>
</div>
</a-flex>
</a-list-item>
</a-list>
</template>
<a-empty v-else :image="sTheme.empty" />
</a-card>
</template>
<style scoped lang="scss">
@use '/src/assets/themes/theme' as *;
@include themed {
.storage-locations {
.storage-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 8px;
border-radius: 4px;
border: 1px solid get-color(border1);
&:last-child {
margin-bottom: 0;
}
.storage-name {
font-weight: 500;
flex-shrink: 0;
margin-right: 8px;
color: get-color(text1);
}
.storage-tags {
flex: 1;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
}
.storage-tag {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
border: 1px solid;
&-error {
color: get-color(error_text);
background-color: get-color(error_bg);
border-color: get-color(error_border);
}
&-success {
color: get-color(success_text);
background-color: get-color(success_bg);
border-color: get-color(success_border);
}
&-warning {
color: get-color(warning_text);
background-color: get-color(warning_bg);
border-color: get-color(warning_border);
}
&-processing {
color: get-color(primary_text);
background-color: get-color(primary_bg);
border-color: get-color(primary_border);
}
&-default {
color: get-color(text2);
background-color: get-color(fill2);
border-color: get-color(border1);
}
}
}
}
}
</style>

View File

@ -2,6 +2,7 @@
import type { RobotRealtimeInfo } from '@api/robot';
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '@api/scene';
import { EditorService } from '@core/editor.service';
import { StorageLocationService } from '@core/storage-location.service';
import { useViewState } from '@core/useViewState';
import { message } from 'ant-design-vue';
import { isNil } from 'lodash-es';
@ -16,18 +17,39 @@ type Props = {
};
const props = defineProps<Props>();
//#region
//
const route = useRoute();
const isMonitorMode = computed(() => route.path.includes('/monitor'));
//#region
//
const title = ref<string>('');
//
const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
const storageLocationService = shallowRef<StorageLocationService>();
const client = shallowRef<WebSocket>();
//
provide(EDITOR_KEY, editor);
//#endregion
//#region
/**
* 读取场景数据
*/
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);
};
/**
* 监控场景中的机器人状态
*/
const monitorScene = async () => {
console.log(current.value?.id);
client.value?.close();
// 使
const ws = isMonitorMode.value ? await monitorRealSceneById(props.sid) : await monitorSceneById(props.sid);
@ -46,29 +68,33 @@ const monitorScene = async () => {
};
//#endregion
const title = ref<string>('');
const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
provide(EDITOR_KEY, editor);
//#region
onMounted(() => {
editor.value = new EditorService(container.value!);
storageLocationService.value = new StorageLocationService(editor.value, props.sid);
});
//#endregion
const client = shallowRef<WebSocket>();
//#region
onMounted(async () => {
await readScene();
await editor.value?.initRobots();
await monitorScene();
await storageLocationService.value?.startMonitoring({ interval: 3 });
//
await handleAutoSaveAndRestoreViewState();
});
onUnmounted(() => {
client.value?.close();
storageLocationService.value?.destroy();
});
//#endregion
const show = ref<boolean>(true);
//#region
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
//
watch(
() => editor.value?.selected.value[0],
(v) => {
@ -81,22 +107,31 @@ watch(
current.value = undefined;
},
);
//
const isRobot = computed(() => current.value?.type === 'robot');
const isPoint = computed(() => current.value?.type === 'point');
const isRoute = computed(() => current.value?.type === 'line');
const isArea = computed(() => current.value?.type === 'area');
/**
* 选择机器人
* @param id 机器人ID
*/
const selectRobot = (id: string) => {
current.value = { type: 'robot', id };
editor.value?.inactive();
//
editor.value?.gotoById(id);
};
//#endregion
//
//#region
const { saveViewState, autoSaveAndRestoreViewState, isSaving } = useViewState();
//
/**
* 保存当前视图状态
*/
const handleSaveViewState = async () => {
if (!editor.value) return;
@ -108,12 +143,19 @@ const handleSaveViewState = async () => {
}
};
//
/**
* 自动保存和恢复视图状态
*/
const handleAutoSaveAndRestoreViewState = async () => {
if (!editor.value) return;
await autoSaveAndRestoreViewState(editor.value, props.sid, props.id);
};
//#endregion
//#region UI
const show = ref<boolean>(true);
//#endregion
</script>
<template>
@ -131,9 +173,9 @@ const handleAutoSaveAndRestoreViewState = async () => {
<a-tab-pane key="1" :tab="$t('机器人')">
<RobotGroups v-if="editor" :token="EDITOR_KEY" :sid="sid" :current="current?.id" @change="selectRobot" />
</a-tab-pane>
<!-- <a-tab-pane key="2" :tab="$t('库区')">
<a-tab-pane key="2" :tab="$t('库区')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" only-area1 />
</a-tab-pane> -->
</a-tab-pane>
<a-tab-pane key="3" :tab="$t('高级组')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" />
</a-tab-pane>
@ -151,7 +193,12 @@ const handleAutoSaveAndRestoreViewState = async () => {
</a-float-button>
<div v-if="show" class="card-container">
<RobotDetailCard v-if="isRobot" :token="EDITOR_KEY" :current="current.id" />
<PointDetailCard v-if="isPoint" :token="EDITOR_KEY" :current="current.id" />
<PointDetailCard
v-if="isPoint"
:token="EDITOR_KEY"
:current="current.id"
:storage-locations="storageLocationService?.getLocationsByPointId(current.id)"
/>
<RouteDetailCard v-if="isRoute" :token="EDITOR_KEY" :current="current.id" />
<AreaDetailCard v-if="isArea" :token="EDITOR_KEY" :current="current.id" />
</div>

View File

@ -609,6 +609,18 @@ export class EditorService extends Meta2d {
this.setValue({ ...pen, id }, { render: true, history: record, doEvent: true });
}
/**
*
* @param pointId ID
* @param color '#ff4d4f' '#52c41a' 绿
*/
public updatePointBorderColor(pointId: string, color: string): void {
const pen = this.getPenById(pointId);
if (!pen || pen.name !== 'point') return;
this.updatePen(pointId, { statusStyle: color }, false);
}
//#region 实时机器人
public async initRobots(): Promise<void> {
await Promise.all(

View File

@ -0,0 +1,203 @@
import type { StorageLocationInfo, StorageLocationMessage } from '@api/scene';
import { monitorStorageLocationById } from '@api/scene';
import type { EditorService } from '@core/editor.service';
import { isNil } from 'lodash-es';
import { type Ref, ref } from 'vue';
/**
*
* WebSocket连接
*/
export class StorageLocationService {
private storageClient: Ref<WebSocket | undefined> = ref();
private storageLocations: Ref<Map<string, StorageLocationInfo[]>> = ref(new Map());
private editor: EditorService | null = null;
private sceneId: string = '';
constructor(editor: EditorService, sceneId: string) {
this.editor = editor;
this.sceneId = sceneId;
}
/**
*
*/
get locations() {
return this.storageLocations;
}
/**
* ID的映射关系
* @returns ID的映射Map
*/
private buildStationToPointIdMap(): Map<string, string> {
const stationToPointIdMap = new Map<string, string>();
if (!this.editor) return stationToPointIdMap;
// 获取所有动作点 (MapPointType.动作点 = 15)
const actionPoints = this.editor.find('point').filter((pen) => pen.point?.type === 15);
actionPoints.forEach((pen) => {
const stationName = pen.label; // 如 "AP9"
const pointId = pen.id; // 如 "3351"
if (stationName && pointId) {
stationToPointIdMap.set(stationName, pointId);
}
});
return stationToPointIdMap;
}
/**
* ID获取对应的画布点ID
* @param locationId ID
* @returns ID或null
*/
private getPointIdByStorageLocationId(locationId: string): string | null {
for (const [pointId, locations] of this.storageLocations.value.entries()) {
if (locations.some((loc) => loc.id === locationId)) {
return pointId;
}
}
return null;
}
/**
* Map中的库位信息
* @param pointId ID
* @param locationId ID
* @param newStatus
*/
private updateStorageLocationInMap(pointId: string, locationId: string, newStatus: Partial<StorageLocationInfo>) {
const locations = this.storageLocations.value.get(pointId);
if (locations) {
const index = locations.findIndex((loc) => loc.id === locationId);
if (index !== -1) {
locations[index] = { ...locations[index], ...newStatus };
}
}
}
/**
*
* @param pointId ID
*/
private updatePointBorderColor(pointId: string) {
const locations = this.storageLocations.value.get(pointId);
if (!locations || locations.length === 0) {
// 没有绑定库位,保持默认灰色
return;
}
const allOccupied = locations.every((loc) => loc.is_occupied);
const color = allOccupied ? '#ff4d4f' : '#52c41a'; // 全部占用红色,否则绿色
// 通知编辑器更新点的边框颜色
this.editor?.updatePointBorderColor(pointId, color);
}
/**
*
*/
private updatePointBorderColors() {
for (const [pointId] of this.storageLocations.value.entries()) {
this.updatePointBorderColor(pointId);
}
}
/**
*
* @param message
*/
private handleStorageLocationUpdate(message: StorageLocationMessage) {
if (message.type === 'storage_location_update') {
// 建立站点名称到画布点ID的映射
const stationToPointIdMap = this.buildStationToPointIdMap();
// 按画布点ID组织库位数据
const locationsByPointId = new Map<string, StorageLocationInfo[]>();
message.data.storage_locations.forEach((location) => {
const stationName = location.station_name; // 如 "AP9"
const pointId = stationToPointIdMap.get(stationName); // 获取对应的画布点ID如 "3351"
if (pointId) {
if (!locationsByPointId.has(pointId)) {
locationsByPointId.set(pointId, []);
}
locationsByPointId.get(pointId)!.push(location);
}
});
this.storageLocations.value = locationsByPointId;
// 更新动作点的边框颜色
this.updatePointBorderColors();
} else if (message.type === 'storage_location_status_change') {
// 处理单个库位状态变化
const { new_status } = message;
const pointId = this.getPointIdByStorageLocationId(new_status.id);
if (pointId) {
this.updateStorageLocationInMap(pointId, new_status.id, new_status);
this.updatePointBorderColor(pointId);
}
}
}
/**
*
* @param options
*/
async startMonitoring(options: { interval?: number } = {}) {
this.stopMonitoring();
// 监控库位状态
const ws = await monitorStorageLocationById(this.sceneId, { interval: options.interval || 3 });
if (isNil(ws)) return;
ws.onmessage = (e) => {
try {
const message = <StorageLocationMessage>JSON.parse(e.data || '{}');
this.handleStorageLocationUpdate(message);
} catch (error) {
console.debug('处理库位状态消息失败:', error);
}
};
// 连接成功后主动请求当前状态
ws.onopen = () => {
const message = {
type: 'get_status',
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(message));
};
this.storageClient.value = ws;
}
/**
*
*/
stopMonitoring() {
this.storageClient.value?.close();
this.storageClient.value = undefined;
}
/**
*
* @param pointId ID
* @returns
*/
getLocationsByPointId(pointId: string): StorageLocationInfo[] | undefined {
return this.storageLocations.value.get(pointId);
}
/**
*
*/
destroy() {
this.stopMonitoring();
this.storageLocations.value.clear();
this.editor = null;
}
}