add:地图编辑
This commit is contained in:
5
nl-vue/.cursor/worktrees.json
Normal file
5
nl-vue/.cursor/worktrees.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"setup-worktree": [
|
||||||
|
"npm install"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal
|
|
||||||
v-model:visible="visible"
|
|
||||||
title="设备属性设置"
|
|
||||||
:width="500"
|
|
||||||
:footer="null"
|
|
||||||
@cancel="handleClose"
|
|
||||||
>
|
|
||||||
<a-form layout="vertical">
|
|
||||||
<a-form-item label="设备ID">
|
|
||||||
<a-input :value="device.id" disabled />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="设备名称">
|
|
||||||
<a-input :value="device.name" @change="handleUpdate('name', $event.target.value)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="设备类型">
|
|
||||||
<a-input :value="device.type" disabled />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="X坐标">
|
|
||||||
<a-input-number
|
|
||||||
:value="device.x"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleUpdate('x', $event)"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="Y坐标">
|
|
||||||
<a-input-number
|
|
||||||
:value="device.y"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleUpdate('y', $event)"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="旋转角度 (度)">
|
|
||||||
<a-input-number
|
|
||||||
:value="device.angle || 0"
|
|
||||||
:min="0"
|
|
||||||
:max="360"
|
|
||||||
:step="1"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleUpdate('angle', $event)"
|
|
||||||
/>
|
|
||||||
<div class="form-hint">0-360度,0度为正东方向,顺时针旋转</div>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="设备大小 (像素)">
|
|
||||||
<a-input-number
|
|
||||||
:value="device.size || 50"
|
|
||||||
:min="10"
|
|
||||||
:step="1"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleUpdate('size', $event)"
|
|
||||||
/>
|
|
||||||
<div class="form-hint">最小10px,无上限</div>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<a-button type="primary" danger block @click="handleDelete">
|
|
||||||
删除设备
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { Modal } from 'ant-design-vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
device: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'update', 'delete'])
|
|
||||||
|
|
||||||
const visible = ref(true)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.device,
|
|
||||||
() => {
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
visible.value = false
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdate = (property, value) => {
|
|
||||||
emit('update', props.device.id, property, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确定要删除这个设备吗?',
|
|
||||||
content: `设备名称: ${props.device.name}`,
|
|
||||||
okText: '确定',
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk() {
|
|
||||||
emit('delete', props.device.id)
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.form-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8b95a5;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -69,7 +69,7 @@ function isImageUrl(str) {
|
|||||||
function renderDeviceIcon(device) {
|
function renderDeviceIcon(device) {
|
||||||
if (isImageUrl(device.icon)) {
|
if (isImageUrl(device.icon)) {
|
||||||
// 如果是图片URL,使用img标签
|
// 如果是图片URL,使用img标签
|
||||||
return `<img src="${device.icon}" alt="${device.name}" style="width: 100%; height: 100%; object-fit: contain;" onerror="this.style.display='none'; this.parentElement.innerHTML='📍';">`;
|
return `<img src="http://localhost:8082"+"${device.icon}" alt="${device.name}" style="width: 100%; height: 100%; object-fit: contain;" onerror="this.style.display='none'; this.parentElement.innerHTML='📍';">`;
|
||||||
} else {
|
} else {
|
||||||
// 如果是emoji或其他文本,直接显示
|
// 如果是emoji或其他文本,直接显示
|
||||||
return device.icon || '📍';
|
return device.icon || '📍';
|
||||||
@@ -100,11 +100,18 @@ function renderDeviceList() {
|
|||||||
<div class="device-category">
|
<div class="device-category">
|
||||||
<div class="category-title">${ type}</div>
|
<div class="category-title">${ type}</div>
|
||||||
${devices.map(device => `
|
${devices.map(device => `
|
||||||
<div class="device-item" draggable="true"
|
<div class="device-item" draggable="true"
|
||||||
data-device-id="${device.id}"
|
data-device-id="${device.id}"
|
||||||
data-device-name="${device.name}"
|
data-device-name="${device.name}"
|
||||||
data-device-type="${device.type}">
|
data-device-type="${device.type}">
|
||||||
<div class="device-icon">${renderDeviceIcon(device)}</div>
|
<div class="device-icon">
|
||||||
|
<img src="http://localhost:8082${device.icon}"
|
||||||
|
alt="${device.name}"
|
||||||
|
style="width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;"
|
||||||
|
onerror="this.style.display='none';">
|
||||||
|
</div>
|
||||||
<div class="device-info">
|
<div class="device-info">
|
||||||
<div class="device-name">${device.name}</div>
|
<div class="device-name">${device.name}</div>
|
||||||
<div class="device-desc">${device.type}</div>
|
<div class="device-desc">${device.type}</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,358 +0,0 @@
|
|||||||
// AGV地图编辑器样式
|
|
||||||
.agv-map-editor {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0f1419 100%);
|
|
||||||
color: #e0e6ed;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部工具栏 */
|
|
||||||
.toolbar {
|
|
||||||
height: 70px;
|
|
||||||
background: rgba(15, 20, 35, 0.95);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 2px solid rgba(0, 255, 200, 0.3);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 30px;
|
|
||||||
box-shadow: 0 4px 30px rgba(0, 255, 200, 0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-title {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, #00ffc8 0%, #00d4ff 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
text-shadow: 0 0 30px rgba(0, 255, 200, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 12px 28px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #00ffc8 0%, #00d4ff 100%);
|
|
||||||
color: #0a0e27;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 255, 200, 0.4);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 30px rgba(0, 255, 200, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #00ffc8;
|
|
||||||
border: 1px solid rgba(0, 255, 200, 0.3);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 255, 200, 0.2);
|
|
||||||
border-color: rgba(0, 255, 200, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主内容区 */
|
|
||||||
.main-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧设备面板 */
|
|
||||||
.device-panel {
|
|
||||||
width: 320px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: rgba(20, 25, 45, 0.8);
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
border-right: 1px solid rgba(0, 255, 200, 0.2);
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 25px 20px;
|
|
||||||
border-bottom: 1px solid rgba(0, 255, 200, 0.2);
|
|
||||||
background: rgba(0, 255, 200, 0.05);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #00ffc8;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-list {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-category {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #00d4ff;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: rgba(0, 255, 200, 0.05);
|
|
||||||
border: 1px solid rgba(0, 255, 200, 0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: grab;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 255, 200, 0.1);
|
|
||||||
border-color: rgba(0, 255, 200, 0.4);
|
|
||||||
transform: translateX(5px);
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 255, 200, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-icon {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 28px;
|
|
||||||
margin-right: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(0, 255, 200, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-name {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e0e6ed;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8b95a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 地图编辑区 */
|
|
||||||
.map-editor {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 地图画布容器 */
|
|
||||||
.map-canvas-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 255, 200, 0.03) 0px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 20px,
|
|
||||||
rgba(0, 255, 200, 0.03) 21px
|
|
||||||
),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(0, 255, 200, 0.03) 0px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 20px,
|
|
||||||
rgba(0, 255, 200, 0.03) 21px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-canvas {
|
|
||||||
min-width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
text-align: center;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-icon {
|
|
||||||
font-size: 80px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-text {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #8b95a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-image {
|
|
||||||
display: block;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.origin-canvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.devices-layer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 20;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 右下角浮动控制面板 */
|
|
||||||
.floating-controls {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 30px;
|
|
||||||
right: 30px;
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
align-items: flex-end;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.origin-info {
|
|
||||||
padding: 12px 20px;
|
|
||||||
background: rgba(20, 25, 45, 0.95);
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
border: 1px solid rgba(0, 255, 200, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #00ffc8;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
background: rgba(20, 25, 45, 0.95);
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
border: 1px solid rgba(0, 255, 200, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #00ffc8;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.3s;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 255, 200, 0.2);
|
|
||||||
border-color: rgba(0, 255, 200, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 25px rgba(0, 255, 200, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: rgba(0, 255, 200, 0.3);
|
|
||||||
border-color: #00ffc8;
|
|
||||||
box-shadow: 0 0 25px rgba(0, 255, 200, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 滚动条样式 */
|
|
||||||
.device-panel::-webkit-scrollbar,
|
|
||||||
.map-canvas-wrapper::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-panel::-webkit-scrollbar-track,
|
|
||||||
.map-canvas-wrapper::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-panel::-webkit-scrollbar-thumb,
|
|
||||||
.map-canvas-wrapper::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 255, 200, 0.3);
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 255, 200, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-canvas-wrapper::-webkit-scrollbar-corner {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
528
nl-vue/src/views/nl_agv/map/index.vue
Normal file
528
nl-vue/src/views/nl_agv/map/index.vue
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
<template>
|
||||||
|
<div class="map-management">
|
||||||
|
<a-card title="地图管理" :bordered="false">
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="showAddModal">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新增地图
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="loadMapList">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<a-form layout="inline">
|
||||||
|
<a-form-item label="地图名称">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.name"
|
||||||
|
placeholder="请输入地图名称"
|
||||||
|
allow-clear
|
||||||
|
@pressEnter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="handleSearch">查询</a-button>
|
||||||
|
<a-button @click="handleReset">重置</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 地图列表 -->
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="mapList"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'preview'">
|
||||||
|
<a-image
|
||||||
|
:src="getImageUrl(record.url)"
|
||||||
|
:width="100"
|
||||||
|
:height="60"
|
||||||
|
:preview="true"
|
||||||
|
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||||
|
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="column.key === 'deviceCount'">
|
||||||
|
<a-badge :count="record.deviceCount || 0" :number-style="{ backgroundColor: '#52c41a' }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="viewMap(record)">查看</a-button>
|
||||||
|
<a-button type="link" size="small" @click="editMap(record)">编辑</a-button>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
:danger="record.status === 1"
|
||||||
|
@click="toggleStatus(record)"
|
||||||
|
>
|
||||||
|
{{ record.status === 1 ? '禁用' : '启用' }}
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除这个地图吗?"
|
||||||
|
ok-text="确定"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="deleteMap(record)"
|
||||||
|
>
|
||||||
|
<a-button type="link" size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑地图弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
:width="800"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
:label-col="{ span: 4 }"
|
||||||
|
:wrapper-col="{ span: 20 }"
|
||||||
|
>
|
||||||
|
<a-form-item label="地图名称" name="name">
|
||||||
|
<a-input v-model:value="formData.name" placeholder="请输入地图名称" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="地图描述" name="description">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formData.description"
|
||||||
|
placeholder="请输入地图描述"
|
||||||
|
:rows="3"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="地图文件" name="url">
|
||||||
|
<a-upload
|
||||||
|
:file-list="fileList"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:custom-request="handleUpload"
|
||||||
|
list-type="picture-card"
|
||||||
|
accept="image/*"
|
||||||
|
:max-count="1"
|
||||||
|
@remove="handleRemove"
|
||||||
|
>
|
||||||
|
<div v-if="fileList.length < 1">
|
||||||
|
<plus-outlined />
|
||||||
|
<div style="margin-top: 8px">上传地图</div>
|
||||||
|
</div>
|
||||||
|
</a-upload>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="地图宽度" name="width">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.width"
|
||||||
|
:min="0"
|
||||||
|
placeholder="自动获取"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="地图高度" name="height">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.height"
|
||||||
|
:min="0"
|
||||||
|
placeholder="自动获取"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="原点X坐标" name="originX">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.originX"
|
||||||
|
:min="0"
|
||||||
|
placeholder="请输入原点X坐标"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="原点Y坐标" name="originY">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.originY"
|
||||||
|
:min="0"
|
||||||
|
placeholder="请输入原点Y坐标"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="状态" name="status">
|
||||||
|
<a-radio-group v-model:value="formData.status">
|
||||||
|
<a-radio :value="1">启用</a-radio>
|
||||||
|
<a-radio :value="0">禁用</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="MapManagement">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import mapApi from '@/api/agv/mapApi'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '地图ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '预览',
|
||||||
|
key: 'preview',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '地图名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '尺寸',
|
||||||
|
key: 'size',
|
||||||
|
customRender: ({ record }) => `${record.width || 0} × ${record.height || 0}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设备数量',
|
||||||
|
key: 'deviceCount',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createTime',
|
||||||
|
key: 'createTime',
|
||||||
|
width: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 250,
|
||||||
|
fixed: 'right'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const mapList = ref([])
|
||||||
|
const searchForm = reactive({
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = computed(() => (formData.id ? '编辑地图' : '新增地图'))
|
||||||
|
const formRef = ref(null)
|
||||||
|
const formData = reactive({
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
url: '',
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
status: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [{ required: true, message: '请输入地图名称', trigger: 'blur' }],
|
||||||
|
url: [{ required: true, message: '请上传地图文件', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件上传
|
||||||
|
const fileList = ref([])
|
||||||
|
|
||||||
|
// 获取图片URL
|
||||||
|
const getImageUrl = (url) => {
|
||||||
|
if (!url) return ''
|
||||||
|
if (url.startsWith('http')) return url
|
||||||
|
return `http://localhost:8082/${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载地图列表
|
||||||
|
const loadMapList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
name: searchForm.name,
|
||||||
|
pageNum: pagination.current,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}
|
||||||
|
const response = await mapApi.list(params)
|
||||||
|
|
||||||
|
if (response && response.data) {
|
||||||
|
mapList.value = response.data.records || response.data.list || []
|
||||||
|
pagination.total = response.data.total || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载地图列表失败:', error)
|
||||||
|
message.error('加载地图列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
loadMapList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.name = ''
|
||||||
|
pagination.current = 1
|
||||||
|
loadMapList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格变化
|
||||||
|
const handleTableChange = (pag) => {
|
||||||
|
pagination.current = pag.current
|
||||||
|
pagination.pageSize = pag.pageSize
|
||||||
|
loadMapList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示新增弹窗
|
||||||
|
const showAddModal = () => {
|
||||||
|
resetForm()
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看地图
|
||||||
|
const viewMap = (record) => {
|
||||||
|
router.push({
|
||||||
|
path: '/nl_agv/screen',
|
||||||
|
query: { mapId: record.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑地图
|
||||||
|
const editMap = (record) => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
description: record.description,
|
||||||
|
url: record.url,
|
||||||
|
width: record.width,
|
||||||
|
height: record.height,
|
||||||
|
originX: record.origin?.x || 0,
|
||||||
|
originY: record.origin?.y || 0,
|
||||||
|
status: record.status
|
||||||
|
})
|
||||||
|
|
||||||
|
if (record.url) {
|
||||||
|
fileList.value = [
|
||||||
|
{
|
||||||
|
uid: '-1',
|
||||||
|
name: 'map.png',
|
||||||
|
status: 'done',
|
||||||
|
url: getImageUrl(record.url)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换状态
|
||||||
|
const toggleStatus = async (record) => {
|
||||||
|
try {
|
||||||
|
await mapApi.enable({
|
||||||
|
id: record.id,
|
||||||
|
status: record.status === 1 ? 0 : 1
|
||||||
|
})
|
||||||
|
message.success('操作成功')
|
||||||
|
loadMapList()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换状态失败:', error)
|
||||||
|
message.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除地图
|
||||||
|
const deleteMap = async (record) => {
|
||||||
|
try {
|
||||||
|
await mapApi.delete({ id: record.id })
|
||||||
|
message.success('删除成功')
|
||||||
|
loadMapList()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除地图失败:', error)
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件上传前
|
||||||
|
const beforeUpload = (file) => {
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件!')
|
||||||
|
}
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('图片大小不能超过 10MB!')
|
||||||
|
}
|
||||||
|
return isImage && isLt10M
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义上传
|
||||||
|
const handleUpload = async ({ file, onSuccess, onError }) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:8081/api/localStorage/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('上传失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.code === 200 && result.data) {
|
||||||
|
formData.url = result.data.url || result.data.path
|
||||||
|
|
||||||
|
// 读取图片尺寸
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
formData.width = img.naturalWidth
|
||||||
|
formData.height = img.naturalHeight
|
||||||
|
}
|
||||||
|
img.src = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
onSuccess(result.data)
|
||||||
|
message.success('上传成功')
|
||||||
|
} else {
|
||||||
|
throw new Error(result.msg || '上传失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败:', error)
|
||||||
|
onError(error)
|
||||||
|
message.error('上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除文件
|
||||||
|
const handleRemove = () => {
|
||||||
|
formData.url = ''
|
||||||
|
fileList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
...formData,
|
||||||
|
origin: {
|
||||||
|
x: formData.originX,
|
||||||
|
y: formData.originY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mapApi.save(data)
|
||||||
|
message.success('保存成功')
|
||||||
|
modalVisible.value = false
|
||||||
|
loadMapList()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error)
|
||||||
|
if (error.errorFields) {
|
||||||
|
// 表单验证失败
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
modalVisible.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
url: '',
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
status: 1
|
||||||
|
})
|
||||||
|
fileList.value = []
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadMapList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.map-management {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
609
src/views/nl_agv/layout/index.vue
Normal file
609
src/views/nl_agv/layout/index.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user