399 lines
8.8 KiB
Vue
399 lines
8.8 KiB
Vue
|
|
<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>
|