diff --git a/src/assets/icons/png/locked.png b/src/assets/icons/png/locked.png new file mode 100644 index 0000000..97f6ee1 Binary files /dev/null and b/src/assets/icons/png/locked.png differ diff --git a/src/services/draw/storage-location-drawer.ts b/src/services/draw/storage-location-drawer.ts index 5cba3ec..307b28b 100644 --- a/src/services/draw/storage-location-drawer.ts +++ b/src/services/draw/storage-location-drawer.ts @@ -1,13 +1,10 @@ import type { MapPen } from '@api/map'; -function roundedRectPath( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - r: number, -) { +// 预加载锁定图标(仅一次) +const lockedIcon = new Image(); +lockedIcon.src = new URL('../../assets/icons/png/locked.png', import.meta.url).toString(); + +function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)); ctx.moveTo(x + rr, y); ctx.lineTo(x + w - rr, y); @@ -27,7 +24,10 @@ function roundedRectPath( export function drawStorageGrid( ctx: CanvasRenderingContext2D, pen: MapPen, - opts?: { fontFamily?: string }, + opts?: { + fontFamily?: string; + stateResolver?: (penId: string, layerName: string) => { occupied?: boolean; locked?: boolean } | undefined; + }, ): void { const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; const assoc: string[] = pen.point?.associatedStorageLocations || []; @@ -58,17 +58,41 @@ export function drawStorageGrid( // 前 5 个格子 + 最后一格可能显示 +N const slots = assoc.slice(0, gridCols * gridRows); - slots.forEach((_, i) => { + slots.forEach((layerName, i) => { const r = Math.floor(i / gridCols); const c = i % gridCols; const cx = gx + c * (cell + gap); const cy = gy + r * (cell + gap); ctx.beginPath(); roundedRectPath(ctx, cx, cy, cell, cell, Math.min(4, cell * 0.3)); - ctx.fillStyle = '#f5f5f5'; + // 解析状态 + const state = opts?.stateResolver?.(pen.id!, layerName) ?? {}; + + const occupied = !!state.occupied; + const locked = !!state.locked; + + // 填充颜色:占用为红色,否则默认灰底 + ctx.fillStyle = occupied ? '#ff4d4f' : '#f5f5f5'; ctx.fill(); ctx.strokeStyle = '#999999'; ctx.stroke(); + + // 若锁定,绘制锁图标角标(尽量不阻塞:未加载完成则跳过) + if (locked && lockedIcon.complete && lockedIcon.naturalWidth > 0) { + const pad = Math.max(1, Math.floor(cell * 0.08)); + // 图标尺寸不超过 cell 内可用空间,且有最小可见阈值 + const maxIcon = Math.max(0, cell - 2 * pad); + const iconSize = Math.min(14, Math.floor(cell * 0.7), maxIcon); + + // 将绘制范围裁剪到当前格子,确保不会溢出 + ctx.save(); + ctx.beginPath(); + roundedRectPath(ctx, cx, cy, cell, cell, Math.min(4, cell * 0.3)); + ctx.clip(); + // 放在格子左上角 + ctx.drawImage(lockedIcon, cx + pad, cy + pad, iconSize, iconSize); + ctx.restore(); + } }); const overflow = Math.max(0, assoc.length - gridCols * gridRows); diff --git a/src/services/editor.service.ts b/src/services/editor.service.ts index b5c951b..aadaf5b 100644 --- a/src/services/editor.service.ts +++ b/src/services/editor.service.ts @@ -32,6 +32,7 @@ import { reactive, watch } from 'vue'; import { AreaOperationService } from './area-operation.service'; import { drawStorageGrid } from './draw/storage-location-drawer'; import { LayerManagerService } from './layer-manager.service'; +import { storageStateMap } from './storage-location.service'; /** * 场景编辑器服务类 @@ -1547,7 +1548,18 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { // 库位2x3栅格:动作点在画布上直接绘制(静态数据) if (type === MapPointType.动作点) { - drawStorageGrid(ctx, pen, { fontFamily }); + drawStorageGrid(ctx, pen, { + fontFamily, + stateResolver: (penId: string, layerName: string) => { + const inner = storageStateMap.get(penId); + if (!inner) { + return {}; + } + const state = inner.get(layerName); + + return state ?? {}; + }, + }); } ctx.restore(); } diff --git a/src/services/storage-location.service.ts b/src/services/storage-location.service.ts index 9d282aa..53735f9 100644 --- a/src/services/storage-location.service.ts +++ b/src/services/storage-location.service.ts @@ -4,6 +4,10 @@ import type { EditorService } from '@core/editor.service'; import { isNil } from 'lodash-es'; import { type Ref, ref } from 'vue'; +// 提供给绘制层快速查询的全局状态映射:pointId -> (layerName -> { occupied, locked }) +export type StorageState = { occupied?: boolean; locked?: boolean }; +export const storageStateMap = new Map>(); + /** * 库位处理服务类 * 负责管理库位数据、WebSocket连接、状态更新和画布点颜色管理 @@ -13,12 +17,26 @@ export class StorageLocationService { private storageLocations: Ref> = ref(new Map()); private editor: EditorService | null = null; private sceneId: string = ''; + // 渲染调度标记,避免在高频事件中重复 render + private renderScheduled = false; constructor(editor: EditorService, sceneId: string) { this.editor = editor; this.sceneId = sceneId; } + /** + * 在下一帧合并渲染请求,避免频繁 render() 抖动 + */ + private scheduleRender() { + if (this.renderScheduled) return; + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderScheduled = false; + this.editor?.render(); + }); + } + /** * 获取库位数据 */ @@ -123,9 +141,9 @@ export class StorageLocationService { // 按画布点ID组织库位数据 const locationsByPointId = new Map(); message.data.storage_locations.forEach((location) => { - const byOperateId = location.operate_point_id; // 直接对应动作点ID - const byStationName = stationToPointIdMap.get(location.station_name); - const pointId = byOperateId || byStationName; + const byStationName = stationToPointIdMap.get(location.station_name); // 优先使用画布上的 pen.id + const byOperateId = location.operate_point_id; // 后端UUID,仅作兜底 + const pointId = byStationName || byOperateId; if (pointId) { if (!locationsByPointId.has(pointId)) { @@ -137,8 +155,20 @@ export class StorageLocationService { this.storageLocations.value = locationsByPointId; + // 同步维护 storageStateMap + storageStateMap.clear(); + for (const [pointId, list] of locationsByPointId.entries()) { + const inner = new Map(); + list.forEach((loc) => { + inner.set(loc.layer_name, { occupied: loc.is_occupied, locked: loc.is_locked }); + }); + storageStateMap.set(pointId, inner); + } + // 更新动作点的边框颜色 this.updatePointBorderColors(); + // 批量更新后触发一次重绘 + this.scheduleRender(); } else if (message.type === 'storage_location_status_change') { // 处理单个库位状态变化 const { new_status } = message; @@ -146,6 +176,28 @@ export class StorageLocationService { if (pointId) { this.updateStorageLocationInMap(pointId, new_status.id, new_status); this.updatePointBorderColor(pointId); + + // 更新 storageStateMap 对应项 + const inner = storageStateMap.get(pointId) ?? new Map(); + // 兼容不同消息结构:优先消息顶层 layer_name,其次从缓存反查 + let layerName: string | undefined; + const msgLoose = message as unknown as { layer_name?: string }; + if (typeof msgLoose.layer_name === 'string') { + layerName = msgLoose.layer_name; + } + if (!layerName) { + const cached = this.storageLocations.value.get(pointId)?.find((loc) => loc.id === new_status.id); + layerName = cached?.layer_name; + } + if (layerName) { + inner.set(layerName, { + occupied: new_status.is_occupied, + locked: new_status.is_locked, + }); + storageStateMap.set(pointId, inner); + } + // 单条状态更新后触发重绘 + this.scheduleRender(); } } } @@ -165,8 +217,8 @@ export class StorageLocationService { try { const message = JSON.parse(e.data || '{}'); this.handleStorageLocationUpdate(message); - } catch (error) { - console.debug('处理库位状态消息失败:', error); + } catch { + // 忽略解析错误,避免打断消息循环 } };