on-site-robots-screen/src/views/home/components/modal-content/preset-setting.vue

685 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 预置位配置 -->
<DialogModal
@onHandleCloseModal="onHandleCloseModal"
:modalTitle="modalTitle"
:width="`90vw`"
style="position: relative"
>
<!-- 平面图操作区域 -->
<div
class="plane-map-container"
ref="planeMapContainer"
:class="{ 'can-drag': canDrag() }"
:style="{ height: INITIAL_HEIGHT + 'px' }"
@mousemove="handleMouseMove"
>
<svg
ref="svgMapRef"
class="svg-map-container"
:width="svgWidth"
:height="svgHeight"
@wheel.passive="handleWheel"
@click="handleMapClick"
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
>
<!-- 图片宽高 100% 跟随 SVG -->
<image
width="100%"
height="100%"
:href="mapInfo.mapBase64"
preserveAspectRatio="none"
/>
<!-- 标记的点位 -->
<circle
v-for="(point, index) in devicePoints"
:key="index"
:cx="point.markerX * scale + offsetX / scale"
:cy="point.markerY1 * scale + offsetY / scale"
r="8"
fill="red"
@click="handlePointClick(point, index)"
:ref="(el) => setItemRef(el, index)"
@contextmenu.prevent="handlePointRightClick($event, point, index)"
/>
</svg>
<div v-if="showTooltip && mousePosition" class="coord-tooltip" :style="tooltipStyle">
<!-- 数据向下取整 -->
X: {{ Math.floor(mousePosition.x) * 2 }}, Y: {{ Math.floor(mousePosition.y) * 2 }}
</div>
<div v-if="showContextMenu" class="context-menu" :style="contextMenuStyle" @click.stop>
<div class="menu-item" @click="handleModifyPoint">修改</div>
<div class="menu-item" @click="handleDeletePoint">删除</div>
</div>
<!-- 操作按钮 -->
<n-flex vertical class="operation-container">
<n-button strong size="small" color="#5c96fa" @click="zoomIn($event)">
<template #icon>
<NIcon color="#fff">
<AddCircleOutline />
</NIcon>
</template>
</n-button>
<n-button strong size="small" color="#5c96fa" @click="zoomOut($event)">
<template #icon>
<NIcon color="#fff">
<RemoveCircleOutline />
</NIcon>
</template>
</n-button>
</n-flex>
</div>
</DialogModal>
</template>
<script setup>
import DialogModal from '@/components/DialogModal/index.vue'
import { ref, onMounted, watch, onBeforeUnmount } from 'vue'
import { NIcon } from 'naive-ui'
import { throttle } from 'lodash'
import { AddCircleOutline, RemoveCircleOutline } from '@vicons/ionicons5'
import { getRobotDeviceListFn, getRobotMapInfoFn } from '@/utils/getRobotInfo'
import { getMarkerListAllApi, deleteMarkerApi } from '@/api/home'
import { useMessage } from 'naive-ui'
const modalTitle = ref('预置位配置') // 模态框标题
const mousePosition = ref(null) // 鼠标位置
const devicePoints = ref([]) // 设备点位
const isPointClickStatus = ref(true) // 是否点击点位
const mapInfo = ref({}) // 地图信息
// SVG 初始尺寸
const INITIAL_WIDTH = ref(0)
const INITIAL_HEIGHT = ref(0)
// SVG 动态宽高
const svgWidth = ref(INITIAL_WIDTH.value)
const svgHeight = ref(INITIAL_HEIGHT.value)
// 拖拽相关状态
const isDragging = ref(false) // 是否拖拽
const offsetX = ref(0) // 偏移量X
const offsetY = ref(0) // 偏移量Y
const startX = ref(0) // 起始X
const startY = ref(0) // 起始Y
const maxOffsetX1 = ref(0) // 最大偏移量X
const maxOffsetY1 = ref(0) // 最大偏移量Y
// 缩放比例
const scale = ref(1.0)
// 缩放配置
const MIN_SCALE = 0.5 // 最小缩放比例
const MAX_SCALE = 3 // 最大缩放比例
const SCALE_STEP = 0.1 // 每次滚轮的缩放步长
// 右键菜单相关状态
const showContextMenu = ref(false) // 是否显示右键菜单
const contextMenuStyle = ref({}) // 右键菜单样式
const selectedPointIndex = ref(-1) // 选中的点位索引
// 容器尺寸跟踪
const planeMapContainer = ref(null) // 容器引用
const svgMapRef = ref(null) // svg引用
const tooltipStyle = ref({}) // 提示框样式
const itemRefs = ref([]) // 标记点位引用
const emits = defineEmits(['onHandleCloseModal', 'onHandleAddMarker']) // 定义 emits
const message = useMessage()
// 定义props
const props = defineProps({
markerInfoNew: {
type: Object,
default: () => {},
},
formType: {
type: String,
default: '',
},
})
// 设置标记点位引用
const setItemRef = (el, index) => {
if (el) {
itemRefs.value[index] = el
}
}
// 缩放时调整偏移量(以鼠标位置为中心)
const adjustOffsetOnZoom = (e, newScale) => {
const container = planeMapContainer.value
if (!container) return
const containerRect = container.getBoundingClientRect()
const mouseX = e ? e.clientX - containerRect.left : containerRect.width / 2
const mouseY = e ? e.clientY - containerRect.top : containerRect.height / 2
// 计算鼠标在内容中的相对位置(基于当前缩放和偏移)
const contentX = (mouseX - offsetX.value) / scale.value
const contentY = (mouseY - offsetY.value) / scale.value
// 更新偏移量(保持鼠标指向的内容位置不变)
offsetX.value = mouseX - contentX * newScale
offsetY.value = mouseY - contentY * newScale
enforceBoundaries() // 确保修正后不越界
}
// 缩放
const zoom = (direction, e = { clientX: 0, clientY: 0 }) => {
const delta = direction === 'in' ? SCALE_STEP : -SCALE_STEP
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale.value + delta))
if (newScale !== scale.value) {
const oldScale = scale.value
scale.value = newScale
updateSvgSize() // 内部已按比例修正偏移量
adjustOffsetOnZoom(e, newScale) // 进一步以鼠标为中心修正
}
}
// 放大
const zoomIn = (e) => zoom('in', e)
// 缩小
const zoomOut = (e) => zoom('out', e)
// 滚轮缩放
const handleWheel = (e) => {
requestAnimationFrame(() => {
e.preventDefault() // 延迟阻止默认行为
zoom(e.deltaY > 0 ? 'out' : 'in', e)
})
}
// 更新 SVG 尺寸
const updateSvgSize = () => {
const oldScale = scale.value
svgWidth.value = INITIAL_WIDTH.value * scale.value
svgHeight.value = INITIAL_HEIGHT.value * scale.value
// 按比例修正偏移量(保持视觉连续性)
if (oldScale !== 0) {
offsetX.value = (offsetX.value / oldScale) * scale.value
offsetY.value = (offsetY.value / oldScale) * scale.value
}
// 确保修正后不越界
enforceBoundaries()
}
// 确保修正后不越界
const enforceBoundaries = () => {
const { maxOffsetX, maxOffsetY } = getMaxOffset()
// 限制偏移量
offsetX.value = Math.min(0, Math.max(-maxOffsetX, offsetX.value))
offsetY.value = Math.min(0, Math.max(-maxOffsetY, offsetY.value))
// 确保鼠标坐标不会越界(二次保护)
if (mousePosition.value) {
mousePosition.value = {
x: Math.max(0, Math.min(svgWidth.value / scale.value, mousePosition.value.x)),
y: Math.max(0, Math.min(svgHeight.value / scale.value, mousePosition.value.y)),
}
}
}
// 开始拖拽
const startDrag = (e) => {
if (!canDrag()) return // 如果不需要拖拽,直接返回
isDragging.value = true
startX.value = e.clientX - offsetX.value
startY.value = e.clientY - offsetY.value
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', stopDrag)
}
// 处理拖拽
const handleDrag = (e) => {
if (!isDragging.value) return
const { maxOffsetX, maxOffsetY } = getMaxOffset()
// 计算新偏移量,并限制范围
let newOffsetX = e.clientX - startX.value
let newOffsetY = e.clientY - startY.value
maxOffsetX1.value = maxOffsetX
maxOffsetY1.value = maxOffsetY
// 限制 X 轴范围(左边界和右边界)
newOffsetX = Math.min(0, newOffsetX) // 不能向右移动(左边界)
newOffsetX = Math.max(-maxOffsetX, newOffsetX) // 不能向左超出(右边界)
// 限制 Y 轴范围(上边界和下边界)
newOffsetY = Math.min(0, newOffsetY) // 不能向下移动(上边界)
newOffsetY = Math.max(-maxOffsetY, newOffsetY) // 不能向上超出(下边界)
offsetX.value = newOffsetX
offsetY.value = newOffsetY
// 强制更新坐标显示
if (showTooltip.value) {
mousePosition.value = getLogicalPosition(e.clientX, e.clientY)
}
}
// 停止拖拽
const stopDrag = () => {
isDragging.value = false
isPointClickStatus.value = true
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
}
// 是否可以拖拽
const canDrag = () => {
const parentWidth = planeMapContainer.value?.clientWidth || 0
const parentHeight = planeMapContainer.value?.clientHeight || 0
return svgWidth.value > parentWidth || svgHeight.value > parentHeight
}
// 计算最大可移动偏移量(限制拖拽边界)
const getMaxOffset = () => {
const parentWidth = planeMapContainer.value?.clientWidth || 0
const parentHeight = planeMapContainer.value?.clientHeight || 0
return {
maxOffsetX: Math.max(0, svgWidth.value - parentWidth), // 右边界
maxOffsetY: Math.max(0, svgHeight.value - parentHeight), // 下边界
}
}
// 获取逻辑坐标
const getLogicalPosition = (clientX, clientY) => {
const svgRect = svgMapRef.value.getBoundingClientRect()
// 计算相对于SVG内容的逻辑坐标考虑缩放和偏移
const logicalX = (clientX - svgRect.left - offsetX.value) / scale.value
// Y坐标需要翻转因为原点在左下角
const logicalY =
svgHeight.value / scale.value - (clientY - svgRect.top - offsetY.value) / scale.value
const logicalY1 = (clientY - svgRect.top - offsetY.value) / scale.value
return {
x: Math.max(0, Math.min(INITIAL_WIDTH.value, logicalX)),
y: Math.max(0, Math.min(INITIAL_HEIGHT.value, logicalY)),
y1: Math.max(0, Math.min(INITIAL_HEIGHT.value, logicalY1)),
}
}
// 鼠标移动事件
const showTooltip = ref(false)
// 鼠标移动事件
const handleMouseMove = throttle((e) => {
if (!svgMapRef.value) return
// 检查鼠标是否在SVG内
const svgRect = svgMapRef.value.getBoundingClientRect()
const isInside =
e.clientX >= svgRect.left &&
e.clientX <= svgRect.right &&
e.clientY >= svgRect.top &&
e.clientY <= svgRect.bottom
showTooltip.value = isInside
if (isInside) {
mousePosition.value = getLogicalPosition(e.clientX, e.clientY)
tooltipStyle.value = {
left: `${e.clientX}px`,
top: `${e.clientY}px`,
}
}
}, 30)
// 左键点击标点
const handleMapClick = (e) => {
isPointClickStatus.value = false
if (!svgMapRef.value || isDragging.value) return // 拖拽过程中不标点
// 判断是否点击了标记的点位
const isPointClick = devicePoints.value.some((point, index) => {
const pointRect = itemRefs.value[index].getBoundingClientRect()
return (
e.clientX >= pointRect.left &&
e.clientX <= pointRect.right &&
e.clientY >= pointRect.top &&
e.clientY <= pointRect.bottom
)
})
if (isPointClick) {
return
}
const svgRect = svgMapRef.value.getBoundingClientRect()
const isInside =
e.clientX >= svgRect.left &&
e.clientX <= svgRect.right &&
e.clientY >= svgRect.top &&
e.clientY <= svgRect.bottom
if (isInside) {
const pos = getLogicalPosition(e.clientX, e.clientY)
addDevicePoint(pos.x, pos.y, pos.y1)
}
}
// 处理右键点击
const handlePointRightClick = (e, point, index) => {
e.preventDefault()
selectedPointIndex.value = index
// 计算菜单位置
const containerRect = planeMapContainer.value.getBoundingClientRect()
const x = e.clientX - containerRect.left
const y = e.clientY - containerRect.top
// 确保菜单不会超出容器边界
const menuWidth = 100
const menuHeight = 80
const adjustedX = x + menuWidth > containerRect.width ? x - menuWidth : x
const adjustedY = y + menuHeight > containerRect.height ? y - menuHeight : y
contextMenuStyle.value = {
left: `${adjustedX + 50}px`,
top: `${adjustedY + 20}px`,
}
showContextMenu.value = true
}
// 关闭菜单
const closeContextMenu = () => {
showContextMenu.value = false
}
// 点击菜单外部关闭菜单
const handleClickOutsideMenu = (e) => {
if (showContextMenu.value && !e.target.closest('.context-menu')) {
closeContextMenu()
}
}
// 修改点位
const handleModifyPoint = () => {
if (selectedPointIndex.value >= 0) {
const point = devicePoints.value[selectedPointIndex.value]
// 这里可以添加修改逻辑,比如弹出对话框
const markerInfo = {
type: '修改',
xCount: point.xCount,
yCount: point.yCount,
markerIndex: selectedPointIndex.value,
markerX: point.markerX,
markerY: point.markerY,
markerY1: point.markerY1,
markerName: point.markerName,
markerAngle: point.markerAngle,
markerPreset: point.markerPreset,
id: point.id,
isAdd: point.isAdd,
}
emits('onHandleAddMarker', markerInfo)
}
closeContextMenu()
}
// 删除点位
const handleDeletePoint = async () => {
if (selectedPointIndex.value >= 0) {
if (devicePoints.value[selectedPointIndex.value].isAdd) {
devicePoints.value.splice(selectedPointIndex.value, 1)
} else {
// 调后台接口删除
const { data: res } = await deleteMarkerApi({
id: devicePoints.value[selectedPointIndex.value].id,
})
if (res.code == 200) {
// 重新获取所有点位
devicePoints.value.splice(selectedPointIndex.value, 1)
}
}
}
closeContextMenu()
}
// 添加设备点位
const addDevicePoint = (x, y, y1) => {
if (isPointClickStatus.value) return
devicePoints.value.push({
xCount: x * 2, // 像素坐标 真实数据
yCount: y * 2, // 像素坐标 真实数据
markerX: x, // 直接存储逻辑坐标
markerY: y, // 直接存储逻辑坐标
markerY1: y1, // 直接存储逻辑坐标
markerName: '',
markerAngle: '', // 角度
markerPreset: '', // 摄像头预置位
isAdd: true,
})
}
// 点击标记的点位
const handlePointClick = (point, index) => {
if (point.id) {
message.warning('点位已存在,无需新增,可右击修改或删除')
return
}
const markerInfo = {
type: '新增',
xCount: point.xCount, // 像素坐标 真实数据
yCount: point.yCount, // 像素坐标 真实数据
markerIndex: index,
markerX: point.markerX,
markerY: point.markerY,
markerY1: point.markerY1,
markerName: point.markerName,
markerAngle: point.markerAngle,
markerPreset: point.markerPreset,
isAdd: point.isAdd,
}
emits('onHandleAddMarker', markerInfo)
}
// 获取全部已经添加的点位
const getMarkerListAll = async () => {
const { data: res } = await getMarkerListAllApi()
// console.log(res, 'res全部点位--')
if (res.code == 200) {
devicePoints.value = []
if (res.data.length > 0) {
const svgRect = svgMapRef.value.getBoundingClientRect()
res.data.forEach((item) => {
// addDevicePoint(item.positionX, item.positionY, item.positionY1)
const clientY =
svgHeight.value -
scale.value * (item.positionY / 2) +
svgRect.top +
offsetY.value
const logicalY1 = (clientY - svgRect.top - offsetY.value) / scale.value
devicePoints.value.push({
...item,
xCount: item.positionX, // 像素坐标 真实数据
yCount: item.positionY, // 像素坐标 真实数据
markerX: item.positionX / 2,
markerY: item.positionY / 2,
markerY1: logicalY1,
markerName: item.pointName,
markerAngle: item.theta,
isAdd: false,
})
})
}
}
}
getMarkerListAll()
// 关闭模态框
const onHandleCloseModal = () => {
emits('onHandleCloseModal')
}
// 监听点击事件以关闭菜单
onMounted(async () => {
document.addEventListener('click', handleClickOutsideMenu)
const svg = svgMapRef.value
svg.addEventListener('wheel', handleWheel, { passive: false }) // 明确声明
const deviceInfo = await getRobotDeviceListFn()
// 获取地图信息
mapInfo.value = await getRobotMapInfoFn(deviceInfo?.puId)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutsideMenu)
})
watch(
() => props.markerInfoNew,
(newVal) => {
// 判断是否为空对象
if (Object.keys(newVal).length > 0) {
// if (props.formType == '新增') {
// getMarkerListAll()
// }
// const svgRect = svgMapRef.value.getBoundingClientRect()
// const clientY =
// svgHeight.value - scale.value * (newVal.yCount / 2) + svgRect.top + offsetY.value
// const logicalY1 = (clientY - svgRect.top - offsetY.value) / scale.value
// devicePoints.value[newVal.markerIndex].markerX = newVal.xCount / 2
// devicePoints.value[newVal.markerIndex].markerY = newVal.markerY
// devicePoints.value[newVal.markerIndex].markerY1 = logicalY1
// devicePoints.value[newVal.markerIndex].markerName = newVal.markerName
// devicePoints.value[newVal.markerIndex].markerAngle = newVal.markerAngle
// devicePoints.value[newVal.markerIndex].markerPreset = newVal.markerPreset
// devicePoints.value[newVal.markerIndex].xCount = newVal.xCount
// devicePoints.value[newVal.markerIndex].yCount = newVal.yCount
}
},
{
immediate: true,
},
)
watch(
() => props.formType,
(newVal) => {
if (newVal == '新增') {
getMarkerListAll()
}
},
{
immediate: true,
},
)
watch(
// 监听地图信息
() => mapInfo.value,
(newVal) => {
if (newVal.mapWidth && newVal.mapHeight) {
INITIAL_WIDTH.value = Math.ceil(newVal.mapWidth / 2)
INITIAL_HEIGHT.value = Math.ceil(newVal.mapHeight / 2)
svgWidth.value = INITIAL_WIDTH.value
svgHeight.value = INITIAL_HEIGHT.value
}
},
)
</script>
<style lang="scss" scoped>
.plane-map-container {
width: 100%;
// height: 80vh;
max-height: 85vh;
overflow: auto;
// 优化横向和竖向滚动条样式
&::-webkit-scrollbar {
width: 6px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&.can-drag {
cursor: grab; /* 可拖拽时显示手型 */
&:active {
cursor: grabbing;
}
}
.svg-map-container {
position: relative;
cursor: default; /* 默认箭头 */
}
circle {
transition: fill 0.2s;
cursor: pointer;
&:hover {
fill: #ff5252;
stroke: white;
stroke-width: 2px;
}
}
// 操作按钮
.operation-container {
position: absolute;
top: 80px;
right: 60px;
z-index: 1000;
gap: 10px;
}
}
.coord-tooltip {
position: fixed; /* 改为fixed定位 */
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
pointer-events: none;
z-index: 1001;
transform: translate(10px, 10px);
}
.context-menu {
position: absolute;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1002;
min-width: 100px;
.menu-item {
padding: 8px 12px;
cursor: pointer;
color: #333;
&:hover {
background-color: #f5f5f5;
}
}
}
</style>