add:地图编辑

This commit is contained in:
zhangzq
2026-01-21 19:39:18 +08:00
parent 19a472edd2
commit ce9740c396
9 changed files with 2801 additions and 485 deletions

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

View File

@@ -0,0 +1,609 @@
<template>
<div class="agv-map-editor">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<h1 class="toolbar-title">AGV地图编辑器</h1>
</div>
<div class="toolbar-right">
<a-button class="btn btn-secondary" @click="handleBack">返回</a-button>
<a-button class="btn btn-primary" @click="saveConfiguration">保存配置</a-button>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 左侧设备面板 -->
<div class="device-panel">
<div class="panel-header">
<h3>设备组件</h3>
</div>
<div class="device-list">
<a-spin v-if="loading" tip="正在加载设备列表..." />
<template v-else>
<div v-for="(devices, type) in groupedDevices" :key="type" class="device-category">
<div class="category-title">{{ type }}</div>
<div
v-for="device in devices"
:key="device.id"
class="device-item"
draggable="true"
:data-device-id="device.id"
@dragstart="handleDragStart($event, device)"
>
<div class="device-icon">
<!-- 如果是emoji直接显示文本 -->
<span v-if="device.icon && device.icon.length <= 2 && !/[\/\\.]/.test(device.icon)">
{{ device.icon }}
</span>
<!-- 否则显示图片 -->
<img
v-else
:src="getImageUrl(device.icon)"
:alt="device.name"
style="width: 100%; height: 100%; object-fit: contain"
@error="handleImageError"
/>
</div>
<div class="device-info">
<div class="device-name">{{ device.name }}</div>
<div class="device-desc">{{ device.type }}</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 地图编辑区 -->
<div class="map-editor">
<input
ref="mapFileInput"
type="file"
accept="image/*"
style="display: none;"
@change="handleMapUpload"
/>
<div
ref="mapCanvasWrapper"
class="map-canvas-wrapper"
@click="handleMapClick"
@dragover="handleDragOver"
@drop="handleDrop"
>
<div ref="mapCanvas" class="map-canvas">
<div v-if="!mapImage" class="placeholder">
<div class="placeholder-icon">🗺</div>
<div class="placeholder-text">点击右下角"上传地图"开始编辑</div>
</div>
<img
v-if="mapImage"
ref="mapImageEl"
:src="mapImage"
class="map-image"
@load="onMapImageLoad"
/>
<canvas ref="originCanvas" class="origin-canvas"></canvas>
<div ref="devicesLayer" class="devices-layer">
<DeviceMarker
v-for="device in devices"
:key="device.id"
:device="device"
:map-width="mapWidth"
:map-height="mapHeight"
:selected="selectedDevice === device.id"
@select="selectDevice"
@update="updateDevice"
@delete="deleteDevice"
/>
</div>
</div>
<!-- 右下角控制面板 -->
<div class="floating-controls">
<div class="origin-info">
原点: ({{ origin.x }}, {{ origin.y }})
</div>
<div class="control-buttons">
<button class="control-btn" @click="$refs.mapFileInput.click()" title="上传地图">
📁
</button>
<button
class="control-btn"
:class="{ active: isSettingOrigin }"
@click="toggleOriginMode"
title="设置原点"
>
📍
</button>
<button class="control-btn" @click="zoomMap(1.2)" title="放大">
🔍+
</button>
<button class="control-btn" @click="zoomMap(0.8)" title="缩小">
🔍-
</button>
<button class="control-btn" @click="resetView" title="重置视图">
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 属性设置浮窗 -->
<PropertyModal
v-if="propertyModalVisible"
:device="currentEditDevice"
@close="propertyModalVisible = false"
@update="updateDeviceProperty"
@delete="deleteDevice"
/>
</div>
</template>
<script setup name="agvMapEditor">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import DeviceMarker from './components/DeviceMarker.vue'
import PropertyModal from './components/PropertyModal.vue'
import fileApi from '@/api/dev/fileApi'
// 状态管理
const loading = ref(false)
const mapImage = ref(null)
const mapId = ref(null)
const mapWidth = ref(0)
const mapHeight = ref(0)
const origin = reactive({ x: 0, y: 0 })
const scale = ref(1)
const isSettingOrigin = ref(false)
const devices = ref([])
const selectedDevice = ref(null)
const deviceIdCounter = ref(1)
const deviceTemplates = ref([])
const propertyModalVisible = ref(false)
const currentEditDevice = ref(null)
const deviceIconUrls = ref({}) // 存储设备图标的 blob URL 映射
// DOM引用
const mapFileInput = ref(null)
const mapCanvasWrapper = ref(null)
const mapCanvas = ref(null)
const mapImageEl = ref(null)
const originCanvas = ref(null)
const devicesLayer = ref(null)
// 计算属性:按类型分组的设备
const groupedDevices = computed(() => {
const grouped = {}
deviceTemplates.value.forEach(device => {
if (!grouped[device.type]) {
grouped[device.type] = []
}
grouped[device.type].push(device)
})
return grouped
})
// 判断是否为图片URL与原始 HTML 逻辑一致)
const isImageUrl = (str) => {
if (!str) return false
// 检查是否为URL路径
return str.startsWith('http://') ||
str.startsWith('https://') ||
str.startsWith('/') ||
str.startsWith('./') ||
str.startsWith('../') ||
// 检查是否包含图片扩展名
/\.(jpg|jpeg|png|gif|svg|webp|bmp)$/i.test(str)
}
// 转换图片路径为可访问的URL
const getImageUrl = (iconPath) => {
if (!iconPath) return ''
// 如果已经是完整的 HTTP/HTTPS URL直接返回
if (iconPath.startsWith('http://') || iconPath.startsWith('https://')) {
return iconPath
}
// 如果是emoji单个或两个字符不包含路径分隔符直接返回
if (iconPath.length <= 2 && !/[\/\\.]/.test(iconPath)) {
return iconPath
}
// 如果是本地文件路径或相对路径,转换为静态服务器地址
// 提取文件名
const fileName = iconPath.split('/').pop().split('\\').pop()
return `http://localhost:81/icon/${fileName}`
}
// 图片加载错误处理
const handleImageError = (e) => {
console.error('图片加载失败:', e.target.src)
e.target.style.display = 'none'
e.target.parentElement.innerHTML = '📍'
}
// 根据文件ID加载图标
const loadDeviceIcon = async (iconId) => {
if (!iconId || deviceIconUrls.value[iconId] || isImageUrl(iconId)) {
return
}
try {
const param = {
storageId: iconId
}
const response = await fileApi.fileDownload(param)
const blob = response.data
if (blob && blob instanceof Blob) {
const url = URL.createObjectURL(blob)
deviceIconUrls.value[iconId] = url
// 强制更新视图
deviceIconUrls.value = { ...deviceIconUrls.value }
}
} catch (error) {
console.error('加载设备图标失败:', iconId, error)
}
}
// 加载设备列表
const loadDeviceList = async () => {
loading.value = true
try {
const response = await fetch('http://localhost:8081/api/device/list', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
if (!response.ok) {
throw new Error(`HTTP错误! 状态: ${response.status}`)
}
const data = await response.json()
if (data && Array.isArray(data.data)) {
deviceTemplates.value = data.data
message.success(`设备列表加载成功,共 ${data.data.length} 个设备`)
// 加载所有设备图标
data.data.forEach(device => {
if (device.icon && !isImageUrl(device.icon)) {
loadDeviceIcon(device.icon)
}
})
}
} catch (error) {
console.error('加载设备列表失败:', error)
message.error('加载设备列表失败: ' + error.message)
} finally {
loading.value = false
}
}
// 处理地图上传
const handleMapUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
if (!file.type.startsWith('image/')) {
message.error('请上传图片文件!')
return
}
const hide = message.loading('正在上传地图...', 0)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fileApi.fileUploadDynamicReturnId(formData)
if (response) {
mapId.value = response.id || response
hide()
message.success(`地图上传成功地图ID: ${mapId.value}`)
// 读取并显示地图
const reader = new FileReader()
reader.onload = (event) => {
mapImage.value = event.target.result
}
reader.readAsDataURL(file)
}
} catch (error) {
hide()
message.error('地图上传失败: ' + error.message)
}
}
// 地图加载完成
const onMapImageLoad = () => {
if (mapImageEl.value) {
mapWidth.value = mapImageEl.value.naturalWidth
mapHeight.value = mapImageEl.value.naturalHeight
initOriginCanvas()
resetView()
}
}
// 初始化原点画布
const initOriginCanvas = () => {
if (!originCanvas.value || !mapImageEl.value) return
const img = mapImageEl.value
originCanvas.value.width = img.naturalWidth
originCanvas.value.height = img.naturalHeight
originCanvas.value.style.width = img.clientWidth + 'px'
originCanvas.value.style.height = img.clientHeight + 'px'
drawOrigin()
}
// 绘制原点
const drawOrigin = () => {
if (!originCanvas.value) return
const canvas = originCanvas.value
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
const { x, y } = origin
// 绘制十字线
ctx.strokeStyle = '#ff6b6b'
ctx.lineWidth = 2
ctx.setLineDash([5, 5])
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, canvas.height)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(canvas.width, y)
ctx.stroke()
// 绘制原点圆圈
ctx.setLineDash([])
ctx.fillStyle = '#ff6b6b'
ctx.beginPath()
ctx.arc(x, y, 8, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(x, y, 8, 0, Math.PI * 2)
ctx.stroke()
}
// 切换原点设置模式
const toggleOriginMode = () => {
if (!mapImage.value) {
message.warning('请先上传地图!')
return
}
isSettingOrigin.value = !isSettingOrigin.value
if (isSettingOrigin.value) {
mapCanvasWrapper.value.style.cursor = 'crosshair'
} else {
mapCanvasWrapper.value.style.cursor = 'default'
}
}
// 处理地图点击
const handleMapClick = (e) => {
if (e.target.closest('.floating-controls')) return
if (isSettingOrigin.value && mapImage.value) {
e.stopPropagation()
e.preventDefault()
const rect = mapImageEl.value.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) / rect.width * mapWidth.value)
const y = Math.round((e.clientY - rect.top) / rect.height * mapHeight.value)
origin.x = x
origin.y = y
drawOrigin()
toggleOriginMode()
message.success(`原点已设置为: (${x}, ${y})`)
return
}
// 点击空白处取消选中
if (!e.target.closest('.placed-device')) {
selectedDevice.value = null
}
}
// 缩放地图
const zoomMap = (factor) => {
scale.value = Math.max(0.5, Math.min(3, scale.value * factor))
updateMapTransform()
}
// 重置视图
const resetView = () => {
scale.value = 1
updateMapTransform()
}
// 更新地图变换
const updateMapTransform = () => {
if (mapCanvas.value) {
mapCanvas.value.style.transform = `scale(${scale.value})`
}
}
// 拖拽开始
const handleDragStart = (e, device) => {
e.dataTransfer.setData('deviceId', device.id)
e.currentTarget.classList.add('dragging')
}
// 拖拽经过
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
// 放置设备
const handleDrop = (e) => {
e.preventDefault()
if (!mapImage.value) {
message.warning('请先上传地图!')
return
}
const deviceId = e.dataTransfer.getData('deviceId')
const template = deviceTemplates.value.find(d => d.id == deviceId)
if (!template) {
message.error('设备模板不存在!')
return
}
const rect = mapImageEl.value.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) / rect.width * mapWidth.value)
const y = Math.round((e.clientY - rect.top) / rect.height * mapHeight.value)
addDevice(template, x, y)
document.querySelectorAll('.device-item').forEach(item => {
item.classList.remove('dragging')
})
}
// 添加设备
const addDevice = (template, x, y) => {
const device = {
id: `device_${deviceIdCounter.value++}`,
templateId: template.id,
name: template.name,
type: template.type,
icon: template.icon,
x: x,
y: y,
angle: 0,
size: 50
}
devices.value.push(device)
message.success(`已添加设备: ${device.name}`)
}
// 选中设备
const selectDevice = (deviceId) => {
selectedDevice.value = deviceId
}
// 更新设备
const updateDevice = (deviceId, updates) => {
const device = devices.value.find(d => d.id === deviceId)
if (device) {
Object.assign(device, updates)
}
}
// 更新设备属性
const updateDeviceProperty = (deviceId, property, value) => {
const device = devices.value.find(d => d.id === deviceId)
if (device) {
device[property] = value
}
}
// 删除设备
const deleteDevice = (deviceId) => {
devices.value = devices.value.filter(d => d.id !== deviceId)
if (selectedDevice.value === deviceId) {
selectedDevice.value = null
}
message.success('设备已删除')
}
// 保存配置
const saveConfiguration = async () => {
if (!mapImage.value) {
message.warning('请先上传地图!')
return
}
if (devices.value.length === 0) {
message.warning('请至少添加一个设备!')
return
}
if (!mapId.value) {
message.error('地图ID不存在请重新上传地图')
return
}
const configuration = {
mapId: mapId.value,
origin: origin,
width: mapWidth.value,
height: mapHeight.value,
devices: devices.value.map(device => ({
id: device.templateId,
name: device.name,
type: device.type,
icon: device.icon,
x: device.x,
y: device.y,
angle: device.angle || 0,
size: Math.floor(device.size) || 50,
param: {}
})),
timestamp: new Date().toISOString()
}
try {
const response = await fetch('http://localhost:8081/api/agv/map/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configuration)
})
if (response.ok) {
message.success('配置保存成功!')
} else {
throw new Error('保存失败')
}
} catch (error) {
console.error('保存配置失败:', error)
message.error('配置保存失败: ' + error.message)
}
}
// 返回
const handleBack = () => {
// 可以使用 router 或者其他方式返回
window.history.back()
}
// 生命周期
onMounted(() => {
loadDeviceList()
})
onUnmounted(() => {
// 清理 blob URL
Object.values(deviceIconUrls.value).forEach(url => {
if (url) {
URL.revokeObjectURL(url)
}
})
deviceIconUrls.value = {}
})
</script>
<style scoped lang="less">
@import './styles/map-editor.less';
</style>