add:地图编辑
This commit is contained in:
398
src/views/nl_agv/layout/components/DeviceMarker.vue
Normal file
398
src/views/nl_agv/layout/components/DeviceMarker.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div
|
||||
class="placed-device"
|
||||
:class="{ selected: selected }"
|
||||
:style="deviceStyle"
|
||||
:data-device-id="device.id"
|
||||
@click.stop="handleClick"
|
||||
@dblclick.stop="handleDoubleClick"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div class="device-marker">
|
||||
<div class="device-marker-icon">
|
||||
<img
|
||||
v-if="isImageUrl(device.icon)"
|
||||
:src="device.icon"
|
||||
:alt="device.name"
|
||||
class="device-icon-img"
|
||||
:style="{ transform: `rotate(${device.angle || 0}deg)` }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
:style="{
|
||||
fontSize: iconFontSize + 'px',
|
||||
transform: `rotate(${device.angle || 0}deg)`,
|
||||
display: 'inline-block'
|
||||
}"
|
||||
>
|
||||
{{ device.icon || '📍' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="device-marker-label">{{ device.name }}</div>
|
||||
</div>
|
||||
<div class="device-delete-btn" @click.stop="handleDelete">×</div>
|
||||
<div v-if="selected" class="device-controls">
|
||||
<div
|
||||
v-for="direction in ['nw', 'ne', 'sw', 'se']"
|
||||
:key="direction"
|
||||
class="resize-handle"
|
||||
:class="`resize-${direction}`"
|
||||
:data-direction="direction"
|
||||
@mousedown.stop="handleResizeStart"
|
||||
></div>
|
||||
<div class="rotate-handle" title="拖拽旋转" @mousedown.stop="handleRotateStart">🔄</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
device: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mapWidth: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
mapHeight: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'update', 'delete'])
|
||||
|
||||
const isDragging = ref(false)
|
||||
const isResizing = ref(false)
|
||||
const isRotating = ref(false)
|
||||
const dragStartPos = ref({ x: 0, y: 0 })
|
||||
const resizeStartSize = ref(0)
|
||||
const resizeStartPos = ref({ x: 0, y: 0 })
|
||||
|
||||
// 计算设备样式
|
||||
const deviceStyle = computed(() => {
|
||||
const leftPercent = (props.device.x / props.mapWidth) * 100
|
||||
const topPercent = (props.device.y / props.mapHeight) * 100
|
||||
const size = props.device.size || 50
|
||||
|
||||
return {
|
||||
left: leftPercent + '%',
|
||||
top: topPercent + '%',
|
||||
width: size + 'px',
|
||||
height: size + 'px'
|
||||
}
|
||||
})
|
||||
|
||||
// 计算图标字体大小
|
||||
const iconFontSize = computed(() => {
|
||||
return Math.round((props.device.size || 50) * 0.64)
|
||||
})
|
||||
|
||||
// 判断是否为可直接访问的图片URL(排除本地文件路径)
|
||||
const isImageUrl = (str) => {
|
||||
if (!str) return false
|
||||
// 只接受 http/https 开头的URL
|
||||
return str.startsWith('http://') || str.startsWith('https://')
|
||||
}
|
||||
|
||||
// 单击选中
|
||||
const handleClick = () => {
|
||||
emit('select', props.device.id)
|
||||
}
|
||||
|
||||
// 双击打开属性面板
|
||||
const handleDoubleClick = () => {
|
||||
// 可以通过事件总线或其他方式打开属性面板
|
||||
console.log('双击设备:', props.device.name)
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.device.id)
|
||||
}
|
||||
|
||||
// 开始拖动设备
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.target.closest('.resize-handle') || e.target.closest('.rotate-handle')) return
|
||||
if (e.target.closest('.device-delete-btn')) return
|
||||
|
||||
isDragging.value = true
|
||||
dragStartPos.value = { x: e.clientX, y: e.clientY }
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove)
|
||||
document.addEventListener('mouseup', handleDragEnd)
|
||||
}
|
||||
|
||||
// 拖动设备
|
||||
const handleDragMove = (e) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = e.clientX - dragStartPos.value.x
|
||||
const deltaY = e.clientY - dragStartPos.value.y
|
||||
|
||||
// 计算新位置
|
||||
const mapEl = document.querySelector('.map-image')
|
||||
if (!mapEl) return
|
||||
|
||||
const rect = mapEl.getBoundingClientRect()
|
||||
const newX = props.device.x + Math.round(deltaX / rect.width * props.mapWidth)
|
||||
const newY = props.device.y + Math.round(deltaY / rect.height * props.mapHeight)
|
||||
|
||||
emit('update', props.device.id, {
|
||||
x: Math.max(0, Math.min(props.mapWidth, newX)),
|
||||
y: Math.max(0, Math.min(props.mapHeight, newY))
|
||||
})
|
||||
|
||||
dragStartPos.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// 结束拖动
|
||||
const handleDragEnd = () => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleDragMove)
|
||||
document.removeEventListener('mouseup', handleDragEnd)
|
||||
}
|
||||
|
||||
// 开始缩放
|
||||
const handleResizeStart = (e) => {
|
||||
isResizing.value = true
|
||||
resizeStartSize.value = props.device.size || 50
|
||||
resizeStartPos.value = { x: e.clientX, y: e.clientY }
|
||||
|
||||
document.addEventListener('mousemove', handleResizeMove)
|
||||
document.addEventListener('mouseup', handleResizeEnd)
|
||||
}
|
||||
|
||||
// 缩放设备
|
||||
const handleResizeMove = (e) => {
|
||||
if (!isResizing.value) return
|
||||
|
||||
const deltaX = e.clientX - resizeStartPos.value.x
|
||||
const deltaY = e.clientY - resizeStartPos.value.y
|
||||
const delta = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
const direction = (deltaX + deltaY) > 0 ? 1 : -1
|
||||
|
||||
const newSize = Math.max(10, resizeStartSize.value + delta * direction * 0.5)
|
||||
emit('update', props.device.id, { size: newSize })
|
||||
}
|
||||
|
||||
// 结束缩放
|
||||
const handleResizeEnd = () => {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', handleResizeMove)
|
||||
document.removeEventListener('mouseup', handleResizeEnd)
|
||||
}
|
||||
|
||||
// 开始旋转
|
||||
const handleRotateStart = (e) => {
|
||||
isRotating.value = true
|
||||
|
||||
document.addEventListener('mousemove', handleRotateMove)
|
||||
document.addEventListener('mouseup', handleRotateEnd)
|
||||
}
|
||||
|
||||
// 旋转设备
|
||||
const handleRotateMove = (e) => {
|
||||
if (!isRotating.value) return
|
||||
|
||||
const deviceEl = e.target.closest('.placed-device')
|
||||
if (!deviceEl) return
|
||||
|
||||
const rect = deviceEl.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * 180 / Math.PI
|
||||
const normalizedAngle = Math.round((angle + 360) % 360)
|
||||
|
||||
emit('update', props.device.id, { angle: normalizedAngle })
|
||||
}
|
||||
|
||||
// 结束旋转
|
||||
const handleRotateEnd = () => {
|
||||
isRotating.value = false
|
||||
document.removeEventListener('mousemove', handleRotateMove)
|
||||
document.removeEventListener('mouseup', handleRotateEnd)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.placed-device {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: move;
|
||||
pointer-events: auto;
|
||||
will-change: transform;
|
||||
|
||||
&:hover {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
z-index: 101;
|
||||
}
|
||||
}
|
||||
|
||||
.device-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.device-marker-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.device-icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.device-marker-label {
|
||||
position: absolute;
|
||||
bottom: -25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border: 1px solid #00ffc8;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #00ffc8;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.device-delete-btn {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ff6b6b;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #ff5252;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.placed-device.selected .device-delete-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.device-controls {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(100% + 40px);
|
||||
height: calc(100% + 40px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #00ffc8;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 200, 0.6);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
background: #00d4ff;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 200, 1);
|
||||
}
|
||||
|
||||
&.resize-nw {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
&.resize-ne {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
&.resize-sw {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
&.resize-se {
|
||||
bottom: -6px;
|
||||
right: -6px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-handle {
|
||||
position: absolute;
|
||||
top: -35px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(255, 107, 107, 0.9);
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 0 15px rgba(255, 107, 107, 0.6);
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.2);
|
||||
background: rgba(255, 107, 107, 1);
|
||||
box-shadow: 0 0 25px rgba(255, 107, 107, 1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user