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

@@ -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-3600度为正东方向顺时针旋转</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>

View File

@@ -69,7 +69,7 @@ function isImageUrl(str) {
function renderDeviceIcon(device) {
if (isImageUrl(device.icon)) {
// 如果是图片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 {
// 如果是emoji或其他文本直接显示
return device.icon || '📍';
@@ -100,11 +100,18 @@ function renderDeviceList() {
<div class="device-category">
<div class="category-title">${ type}</div>
${devices.map(device => `
<div class="device-item" draggable="true"
<div class="device-item" draggable="true"
data-device-id="${device.id}"
data-device-name="${device.name}"
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-name">${device.name}</div>
<div class="device-desc">${device.type}</div>

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

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