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>
|