add:增加地图列表功能,及路由跳转
This commit is contained in:
@@ -16,6 +16,7 @@ import Layout from '@/layout/index.vue'
|
||||
import Login from '@/views/auth/login/login.vue'
|
||||
import Findpwd from '@/views/auth/findPwd/index.vue'
|
||||
import Callback from '@/views/auth/login/callback.vue'
|
||||
import AgvMapEditor from '@/views/nl_agv/layout/index.vue'
|
||||
|
||||
// 系统路由
|
||||
const routes = [
|
||||
@@ -46,6 +47,15 @@ const routes = [
|
||||
meta: {
|
||||
title: '三方登录'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/nl_agv/layout',
|
||||
name: 'AgvMapEditor',
|
||||
component: AgvMapEditor,
|
||||
meta: {
|
||||
title: 'AGV地图编辑器',
|
||||
fullpage: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
483
nl-vue/src/views/nl_agv/map/detail.vue
Normal file
483
nl-vue/src/views/nl_agv/map/detail.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<xn-form-container
|
||||
title="地图详情"
|
||||
:width="1200"
|
||||
:visible="visible"
|
||||
:destroy-on-close="true"
|
||||
@close="onClose"
|
||||
>
|
||||
<a-spin :spinning="detailLoading" tip="加载中...">
|
||||
<div class="detail-content">
|
||||
<!-- 基本信息 -->
|
||||
<div class="info-section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<a-descriptions bordered :column="2" size="middle">
|
||||
<a-descriptions-item label="地图ID">
|
||||
{{ detailData.mapId || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="地图尺寸">
|
||||
{{ detailData.width }} × {{ detailData.height }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="原点坐标" :span="2">
|
||||
X: {{ detailData.origin?.x || 0 }}, Y: {{ detailData.origin?.y || 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="地图URL" :span="2">
|
||||
{{ detailData.url || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 地图预览 -->
|
||||
<div class="map-section">
|
||||
<div class="section-title">地图预览</div>
|
||||
<div class="map-preview-container">
|
||||
<a-spin :spinning="imageLoading" tip="加载地图中...">
|
||||
<div v-if="imagePreviewUrl" class="map-canvas-wrapper">
|
||||
<canvas
|
||||
ref="mapCanvasRef"
|
||||
class="map-canvas"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@wheel="handleWheel"
|
||||
></canvas>
|
||||
<div class="map-controls">
|
||||
<a-button-group>
|
||||
<a-button @click="zoomIn">
|
||||
<template #icon><plus-outlined /></template>
|
||||
</a-button>
|
||||
<a-button @click="zoomOut">
|
||||
<template #icon><minus-outlined /></template>
|
||||
</a-button>
|
||||
<a-button @click="resetView">
|
||||
<template #icon><reload-outlined /></template>
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无地图" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<div class="devices-section" v-if="detailData.devices && detailData.devices.length > 0">
|
||||
<div class="section-title">设备列表 ({{ detailData.devices.length }})</div>
|
||||
<a-table
|
||||
:columns="deviceColumns"
|
||||
:data-source="detailData.devices"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 300 }"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'position'">
|
||||
X: {{ record.x }}, Y: {{ record.y }}
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'angle'">
|
||||
{{ record.angle }}°
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="onClose">关闭</a-button>
|
||||
</template>
|
||||
</xn-form-container>
|
||||
</template>
|
||||
|
||||
<script setup name="nlAgvMapDetail">
|
||||
import mapApi from '@/api/agv/mapApi'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 文件服务器地址
|
||||
const FILE_SERVER = 'http://localhost:8082'
|
||||
|
||||
// 默认是关闭状态
|
||||
const visible = ref(false)
|
||||
|
||||
// 详情数据
|
||||
const detailData = ref({})
|
||||
const imagePreviewUrl = ref('') // 地图图片URL
|
||||
const imageLoading = ref(false) // 图片加载状态
|
||||
const detailLoading = ref(false) // 详情加载状态
|
||||
const mapCanvasRef = ref(null)
|
||||
const mapImage = ref(null)
|
||||
|
||||
// 预加载的设备图片缓存
|
||||
const deviceImageCache = ref(new Map())
|
||||
|
||||
// 画布相关状态
|
||||
const canvasState = ref({
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
isDragging: false,
|
||||
lastX: 0,
|
||||
lastY: 0
|
||||
})
|
||||
|
||||
// 设备列表列
|
||||
const deviceColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '位置坐标',
|
||||
dataIndex: 'position',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '角度',
|
||||
dataIndex: 'angle',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '尺寸',
|
||||
dataIndex: 'size',
|
||||
width: 100
|
||||
}
|
||||
]
|
||||
|
||||
// 加载地图图片
|
||||
const loadMapImage = (url) => {
|
||||
if (!url) return
|
||||
|
||||
imageLoading.value = true
|
||||
const img = new Image()
|
||||
|
||||
// 构建完整的图片URL
|
||||
let imageUrl = url
|
||||
if (url.startsWith('/Users/') || url.startsWith('C:\\') || url.startsWith('/')) {
|
||||
// 本地文件路径,通过文件服务器访问
|
||||
imageUrl = FILE_SERVER + url
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
mapImage.value = img
|
||||
imagePreviewUrl.value = imageUrl
|
||||
imageLoading.value = false
|
||||
// 图片加载完成后绘制
|
||||
nextTick(() => {
|
||||
drawMap()
|
||||
})
|
||||
}
|
||||
img.onerror = () => {
|
||||
message.error('地图图片加载失败')
|
||||
imageLoading.value = false
|
||||
}
|
||||
|
||||
img.src = imageUrl
|
||||
}
|
||||
|
||||
// 绘制地图
|
||||
const drawMap = () => {
|
||||
if (!mapCanvasRef.value || !mapImage.value) return
|
||||
|
||||
const canvas = mapCanvasRef.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
const { scale, offsetX, offsetY } = canvasState.value
|
||||
|
||||
// 设置画布尺寸
|
||||
canvas.width = canvas.offsetWidth
|
||||
canvas.height = canvas.offsetHeight
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制背景
|
||||
ctx.fillStyle = '#f0f0f0'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 保存当前状态
|
||||
ctx.save()
|
||||
|
||||
// 应用变换
|
||||
ctx.translate(offsetX, offsetY)
|
||||
ctx.scale(scale, scale)
|
||||
|
||||
// 绘制地图图片
|
||||
ctx.drawImage(mapImage.value, 0, 0)
|
||||
|
||||
// 绘制设备
|
||||
if (detailData.value.devices && detailData.value.devices.length > 0) {
|
||||
detailData.value.devices.forEach(device => {
|
||||
drawDevice(ctx, device)
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复状态
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 预加载设备图片
|
||||
const preloadDeviceImages = (devices) => {
|
||||
if (!devices || devices.length === 0) return
|
||||
|
||||
devices.forEach(device => {
|
||||
if (device.icon && !deviceImageCache.value.has(device.icon)) {
|
||||
const img = new Image()
|
||||
const iconUrl = device.icon.startsWith('/') ? FILE_SERVER + device.icon : device.icon
|
||||
|
||||
img.onload = () => {
|
||||
deviceImageCache.value.set(device.icon, img)
|
||||
// 图片加载完成后重新绘制
|
||||
drawMap()
|
||||
}
|
||||
img.onerror = () => {
|
||||
console.error('设备图标加载失败:', iconUrl)
|
||||
}
|
||||
// 不设置 crossOrigin,避免 CORS 问题
|
||||
img.src = iconUrl
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制设备
|
||||
const drawDevice = (ctx, device) => {
|
||||
const { x, y, angle, size, name, icon } = device
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate((angle * Math.PI) / 180)
|
||||
|
||||
// 绘制设备矩形
|
||||
const halfSize = size / 2
|
||||
ctx.fillStyle = 'rgba(24, 144, 255, 0.3)'
|
||||
ctx.strokeStyle = '#1890ff'
|
||||
ctx.lineWidth = 2
|
||||
ctx.fillRect(-halfSize, -halfSize, size, size)
|
||||
ctx.strokeRect(-halfSize, -halfSize, size, size)
|
||||
|
||||
// 如果有设备图标且已缓存,绘制图标
|
||||
if (icon && deviceImageCache.value.has(icon)) {
|
||||
const deviceImg = deviceImageCache.value.get(icon)
|
||||
try {
|
||||
ctx.drawImage(deviceImg, -halfSize, -halfSize, size, size)
|
||||
} catch (error) {
|
||||
console.error('绘制设备图标失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制方向指示
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(halfSize, 0)
|
||||
ctx.strokeStyle = '#ff4d4f'
|
||||
ctx.lineWidth = 3
|
||||
ctx.stroke()
|
||||
|
||||
ctx.restore()
|
||||
|
||||
// 绘制设备名称
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.font = '14px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(name || '', x, y - size / 2 - 10)
|
||||
}
|
||||
|
||||
// 鼠标滚轮缩放
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
canvasState.value.scale *= delta
|
||||
canvasState.value.scale = Math.max(0.1, Math.min(5, canvasState.value.scale))
|
||||
drawMap()
|
||||
}
|
||||
|
||||
// 鼠标拖拽
|
||||
const handleMouseDown = (e) => {
|
||||
canvasState.value.isDragging = true
|
||||
canvasState.value.lastX = e.clientX
|
||||
canvasState.value.lastY = e.clientY
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!canvasState.value.isDragging) return
|
||||
|
||||
const dx = e.clientX - canvasState.value.lastX
|
||||
const dy = e.clientY - canvasState.value.lastY
|
||||
|
||||
canvasState.value.offsetX += dx
|
||||
canvasState.value.offsetY += dy
|
||||
canvasState.value.lastX = e.clientX
|
||||
canvasState.value.lastY = e.clientY
|
||||
|
||||
drawMap()
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
canvasState.value.isDragging = false
|
||||
}
|
||||
|
||||
// 放大
|
||||
const zoomIn = () => {
|
||||
canvasState.value.scale *= 1.2
|
||||
canvasState.value.scale = Math.min(5, canvasState.value.scale)
|
||||
drawMap()
|
||||
}
|
||||
|
||||
// 缩小
|
||||
const zoomOut = () => {
|
||||
canvasState.value.scale *= 0.8
|
||||
canvasState.value.scale = Math.max(0.1, canvasState.value.scale)
|
||||
drawMap()
|
||||
}
|
||||
|
||||
// 重置视图
|
||||
const resetView = () => {
|
||||
canvasState.value.scale = 1
|
||||
canvasState.value.offsetX = 0
|
||||
canvasState.value.offsetY = 0
|
||||
drawMap()
|
||||
}
|
||||
|
||||
// 打开抽屉
|
||||
const onOpen = (record) => {
|
||||
visible.value = true
|
||||
detailData.value = {}
|
||||
imagePreviewUrl.value = ''
|
||||
mapImage.value = null
|
||||
deviceImageCache.value.clear()
|
||||
detailLoading.value = true
|
||||
|
||||
// 重置画布状态
|
||||
canvasState.value = {
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
isDragging: false,
|
||||
lastX: 0,
|
||||
lastY: 0
|
||||
}
|
||||
|
||||
if (record) {
|
||||
const param = {
|
||||
id: record.id
|
||||
}
|
||||
mapApi
|
||||
.initLayout(param)
|
||||
.then((data) => {
|
||||
detailData.value = Object.assign({}, data)
|
||||
// 预加载设备图片
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
preloadDeviceImages(data.devices)
|
||||
}
|
||||
// 加载地图图片
|
||||
if (data.url) {
|
||||
loadMapImage(data.url)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error('获取详情失败: ' + (error.message || error))
|
||||
})
|
||||
.finally(() => {
|
||||
detailLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const onClose = () => {
|
||||
detailData.value = {}
|
||||
imagePreviewUrl.value = ''
|
||||
mapImage.value = null
|
||||
deviceImageCache.value.clear()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 调用这个函数将子组件的一些数据和方法暴露出去
|
||||
defineExpose({
|
||||
onOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
padding-left: 12px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 120px;
|
||||
font-weight: 500;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-content) {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.map-section {
|
||||
.map-preview-container {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
min-height: 500px;
|
||||
|
||||
.map-canvas-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.devices-section {
|
||||
:deep(.ant-table) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
402
nl-vue/src/views/nl_agv/map/form.vue
Normal file
402
nl-vue/src/views/nl_agv/map/form.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<xn-form-container
|
||||
:title="formData.id ? '编辑地图信息' : '新增地图信息'"
|
||||
:width="900"
|
||||
:visible="visible"
|
||||
:destroy-on-close="true"
|
||||
@close="onClose"
|
||||
>
|
||||
<div class="form-content">
|
||||
<!-- 左侧:地图预览区域 -->
|
||||
<div class="image-preview-section" v-if="formData.id && imagePreviewUrl">
|
||||
<div class="preview-title">地图预览</div>
|
||||
<div class="preview-container">
|
||||
<a-spin :spinning="imageLoading" tip="加载中...">
|
||||
<a-image v-if="imagePreviewUrl" :src="imagePreviewUrl" :preview="true" class="map-image" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:表单区域 -->
|
||||
<div class="form-section" :class="{ 'full-width': !formData.id || !imagePreviewUrl }">
|
||||
<a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
|
||||
<a-form-item label="地图ID:" name="mapId">
|
||||
<a-input-number
|
||||
v-model:value="formData.mapId"
|
||||
placeholder="请输入地图ID"
|
||||
:disabled="!!formData.id"
|
||||
style="width: 100%"
|
||||
:min="1"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="地图名称:" name="mapName">
|
||||
<a-input
|
||||
v-model:value="formData.mapName"
|
||||
placeholder="请输入地图名称"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="版本号:" name="version">
|
||||
<a-input-number
|
||||
v-model:value="formData.version"
|
||||
placeholder="请输入版本号"
|
||||
style="width: 100%"
|
||||
:min="1"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="地图宽度:" name="width">
|
||||
<a-input-number
|
||||
v-model:value="formData.width"
|
||||
placeholder="宽度"
|
||||
style="width: 100%"
|
||||
:min="1"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="地图高度:" name="height">
|
||||
<a-input-number
|
||||
v-model:value="formData.height"
|
||||
placeholder="高度"
|
||||
style="width: 100%"
|
||||
:min="1"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="原点X坐标:" name="x">
|
||||
<a-input-number
|
||||
v-model:value="formData.x"
|
||||
placeholder="X坐标"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="原点Y坐标:" name="y">
|
||||
<a-input-number
|
||||
v-model:value="formData.y"
|
||||
placeholder="Y坐标"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="地图文件:" name="url">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
class="map-uploader"
|
||||
:show-upload-list="true"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handleUpload"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
:max-count="1"
|
||||
>
|
||||
<div v-if="fileList.length < 1">
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">上传地图</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">支持jpg、png格式,建议上传清晰的地图图片</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="启用状态:" name="enable">
|
||||
<a-switch
|
||||
v-model:checked="formData.enable"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button class="xn-mr8" @click="onClose">关闭</a-button>
|
||||
<a-button type="primary" @click="onSubmit" :loading="submitLoading">保存</a-button>
|
||||
</template>
|
||||
</xn-form-container>
|
||||
</template>
|
||||
|
||||
<script setup name="nlAgvMapForm">
|
||||
import { required } from '@/utils/formRules'
|
||||
import mapApi from '@/api/agv/mapApi'
|
||||
import fileApi from '@/api/dev/fileApi'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 文件服务器地址
|
||||
const FILE_SERVER = 'http://localhost:8082'
|
||||
|
||||
// 默认是关闭状态
|
||||
const visible = ref(false)
|
||||
const emit = defineEmits({ successful: null })
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
enable: false,
|
||||
version: 1,
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const submitLoading = ref(false)
|
||||
const fileList = ref([])
|
||||
const imagePreviewUrl = ref('') // 图片预览URL
|
||||
const imageLoading = ref(false) // 图片加载状态
|
||||
|
||||
// 加载地图图片
|
||||
const loadMapImage = (url, updateFileList = false) => {
|
||||
if (!url) return
|
||||
|
||||
imageLoading.value = true
|
||||
|
||||
// 构建完整的图片URL
|
||||
let imageUrl = url
|
||||
if (url.startsWith('/Users/') || url.startsWith('C:\\') || url.startsWith('/')) {
|
||||
// 本地文件路径,通过文件服务器访问
|
||||
imageUrl = FILE_SERVER + url
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imagePreviewUrl.value = imageUrl
|
||||
|
||||
// 如果需要更新文件列表(编辑时)
|
||||
if (updateFileList) {
|
||||
fileList.value = [
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'map-image.png',
|
||||
status: 'done',
|
||||
url: imageUrl,
|
||||
thumbUrl: imageUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
imageLoading.value = false
|
||||
}
|
||||
img.onerror = () => {
|
||||
message.error('地图图片加载失败')
|
||||
imageLoading.value = false
|
||||
}
|
||||
img.src = imageUrl
|
||||
}
|
||||
|
||||
// 打开抽屉
|
||||
const onOpen = (record) => {
|
||||
visible.value = true
|
||||
formData.value = {
|
||||
enable: false,
|
||||
version: 1,
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
fileList.value = []
|
||||
imagePreviewUrl.value = ''
|
||||
|
||||
if (record) {
|
||||
submitLoading.value = true
|
||||
const param = {
|
||||
id: record.id
|
||||
}
|
||||
mapApi
|
||||
.detail(param)
|
||||
.then((data) => {
|
||||
formData.value = Object.assign({}, data)
|
||||
// 如果有地图URL,加载图片
|
||||
if (data.url) {
|
||||
loadMapImage(data.url, true)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error('获取详情失败: ' + (error.message || error))
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const onClose = () => {
|
||||
formRef.value.resetFields()
|
||||
fileList.value = []
|
||||
imagePreviewUrl.value = ''
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 默认要校验的
|
||||
const formRules = {
|
||||
mapId: [required('请输入地图ID')],
|
||||
mapName: [required('请输入地图名称')],
|
||||
version: [required('请输入版本号')],
|
||||
width: [required('请输入地图宽度')],
|
||||
height: [required('请输入地图高度')],
|
||||
url: [required('请上传地图文件')]
|
||||
}
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = (file) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的图片!')
|
||||
return false
|
||||
}
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isLt10M) {
|
||||
message.error('图片大小不能超过 10MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 自定义上传
|
||||
const handleUpload = ({ file, onSuccess, onError }) => {
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
fileApi
|
||||
.fileUploadDynamicReturnId(uploadFormData)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
// 保存文件路径到表单数据
|
||||
formData.value.url = res.filePath || res.url || res
|
||||
message.success('上传成功')
|
||||
|
||||
// 加载新上传的图片预览
|
||||
if (formData.value.url) {
|
||||
loadMapImage(formData.value.url)
|
||||
}
|
||||
|
||||
onSuccess(res)
|
||||
} else {
|
||||
message.error('上传失败')
|
||||
onError(new Error('上传失败'))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error('上传失败: ' + error.message)
|
||||
onError(error)
|
||||
})
|
||||
}
|
||||
|
||||
// 删除图片
|
||||
const handleRemove = () => {
|
||||
formData.value.url = ''
|
||||
imagePreviewUrl.value = ''
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const handlePreview = async (file) => {
|
||||
// 如果已经有预览URL,直接使用
|
||||
if (imagePreviewUrl.value) {
|
||||
return
|
||||
}
|
||||
// 如果是新上传的文件,使用file对象创建预览
|
||||
if (file.originFileObj) {
|
||||
imagePreviewUrl.value = URL.createObjectURL(file.originFileObj)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证并提交数据
|
||||
const onSubmit = () => {
|
||||
formRef.value
|
||||
.validate()
|
||||
.then(() => {
|
||||
submitLoading.value = true
|
||||
const apiMethod = formData.value.id ? mapApi.edit : mapApi.save
|
||||
apiMethod(formData.value)
|
||||
.then(() => {
|
||||
message.success('保存成功')
|
||||
onClose()
|
||||
emit('successful')
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error('保存失败: ' + (error.message || error))
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 调用这个函数将子组件的一些数据和方法暴露出去
|
||||
defineExpose({
|
||||
onOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.image-preview-section {
|
||||
flex: 0 0 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.preview-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
|
||||
.map-image {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&.full-width {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.map-uploader {
|
||||
:deep(.ant-upload-select-picture-card) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-list-picture-card-container) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.xn-mr8 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
236
nl-vue/src/views/nl_agv/map/index.vue
Normal file
236
nl-vue/src/views/nl_agv/map/index.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<a-card :bordered="false" :body-style="{ 'padding-bottom': '0px' }" class="mb-2">
|
||||
<a-form ref="searchFormRef" name="advanced_search" :model="searchFormState" class="ant-advanced-search-form">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="6">
|
||||
<a-form-item label="关键词" name="searchKey">
|
||||
<a-input v-model:value="searchFormState.searchKey" placeholder="请输入地图名称或ID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item label="启用状态" name="enable">
|
||||
<a-select v-model:value="searchFormState.enable" placeholder="请选择启用状态" allow-clear>
|
||||
<a-select-option :value="true">已启用</a-select-option>
|
||||
<a-select-option :value="false">未启用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button type="primary" @click="tableRef.refresh(true)">查询</a-button>
|
||||
<a-button class="xn-mg08" @click="reset">重置</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-card>
|
||||
<a-card :bordered="false">
|
||||
<s-table
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:data="loadData"
|
||||
:alert="options.alert.show"
|
||||
bordered
|
||||
:row-key="(record) => record.id"
|
||||
:tool-config="toolConfig"
|
||||
:row-selection="options.rowSelection"
|
||||
>
|
||||
<template #operator class="table-operator">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="goToMapEditor()">
|
||||
<template #icon><plus-outlined /></template>
|
||||
新增地图
|
||||
</a-button>
|
||||
<xn-batch-button
|
||||
buttonName="批量删除"
|
||||
icon="DeleteOutlined"
|
||||
buttonDanger
|
||||
:selectedRowKeys="selectedRowKeys"
|
||||
@batchCallBack="deleteBatchMap"
|
||||
/>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'mapId'">
|
||||
<span>{{ record.mapId }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'mapName'">
|
||||
<span>{{ record.mapName || '-' }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'version'">
|
||||
<a-tag color="blue">v{{ record.version }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'size'">
|
||||
<span>{{ record.width }} × {{ record.height }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'origin'">
|
||||
<span>X: {{ record.x }}, Y: {{ record.y }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'enable'">
|
||||
<a-switch
|
||||
v-model:checked="record.enable"
|
||||
@change="handleEnableChange(record)"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a @click="showDetail(record)">详情</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="formRef.onOpen(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确定要删除此地图吗?" @confirm="deleteMap(record)">
|
||||
<a-button type="link" danger size="small">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</s-table>
|
||||
</a-card>
|
||||
<Form ref="formRef" @successful="tableRef.refresh(true)" />
|
||||
<Detail ref="detailRef" />
|
||||
</template>
|
||||
|
||||
<script setup name="nlAgvMap">
|
||||
import { useRouter } from 'vue-router'
|
||||
import Form from './form.vue'
|
||||
import Detail from './detail.vue'
|
||||
import mapApi from '@/api/agv/mapApi'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const searchFormState = ref({})
|
||||
const searchFormRef = ref()
|
||||
const tableRef = ref()
|
||||
const formRef = ref()
|
||||
const detailRef = ref()
|
||||
const toolConfig = { refresh: true, height: true, columnSetting: false, striped: false }
|
||||
|
||||
// 文件服务器地址
|
||||
const FILE_SERVER = 'http://localhost:8082'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '地图ID',
|
||||
dataIndex: 'mapId',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
title: '地图名称',
|
||||
dataIndex: 'mapName',
|
||||
width: '200px'
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
dataIndex: 'version',
|
||||
width: '100px'
|
||||
},
|
||||
{
|
||||
title: '尺寸(宽×高)',
|
||||
dataIndex: 'size',
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
title: '原点坐标',
|
||||
dataIndex: 'origin',
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
title: '启用状态',
|
||||
dataIndex: 'enable',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
align: 'center',
|
||||
width: '220px'
|
||||
}
|
||||
]
|
||||
|
||||
let selectedRowKeys = ref([])
|
||||
|
||||
// 列表选择配置
|
||||
const options = {
|
||||
alert: {
|
||||
show: false,
|
||||
clear: () => {
|
||||
selectedRowKeys = ref([])
|
||||
}
|
||||
},
|
||||
rowSelection: {
|
||||
onChange: (selectedRowKey, selectedRows) => {
|
||||
selectedRowKeys.value = selectedRowKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = (parameter) => {
|
||||
return mapApi.list(Object.assign(parameter, searchFormState.value)).then((res) => {
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
searchFormRef.value.resetFields()
|
||||
tableRef.value.refresh(true)
|
||||
}
|
||||
|
||||
// 启用/禁用地图
|
||||
const handleEnableChange = (record) => {
|
||||
const params = {
|
||||
id: record.id,
|
||||
enable: record.enable
|
||||
}
|
||||
mapApi.enable(params).then(() => {
|
||||
message.success(record.enable ? '已启用' : '已禁用')
|
||||
tableRef.value.refresh(false)
|
||||
}).catch(() => {
|
||||
// 失败时恢复原状态
|
||||
record.enable = !record.enable
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
const deleteMap = (record) => {
|
||||
let params = [
|
||||
{
|
||||
id: record.id
|
||||
}
|
||||
]
|
||||
mapApi.delete(params).then(() => {
|
||||
message.success('删除成功')
|
||||
tableRef.value.refresh(true)
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const deleteBatchMap = (params) => {
|
||||
mapApi.delete(params).then(() => {
|
||||
message.success('批量删除成功')
|
||||
tableRef.value.clearRefreshSelected()
|
||||
})
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
const showDetail = (record) => {
|
||||
if (detailRef.value) {
|
||||
detailRef.value.onOpen(record)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到地图编辑页面
|
||||
const goToMapEditor = () => {
|
||||
router.push('/nl_agv/layout')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.mb-2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xn-mg08 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user