无人机管理后台前端(已迁走)
shuishen
2025-09-12 a2bb326dfbc6024916d360b89d021243f8502e33
feat:空域录入相关调整
3 files modified
5 files added
1211 ■■■■■ changed files
src/assets/images/mapEdit/newcon_box.png patch | view | raw | blame | history
src/styles/element-ui.scss 4 ●●●● patch | view | raw | blame | history
src/utils/drawPolygon/drawPolygon.css 28 ●●●●● patch | view | raw | blame | history
src/utils/drawPolygon/drawPolygon.js 613 ●●●●● patch | view | raw | blame | history
src/utils/mapToolTip/prompt.css 56 ●●●●● patch | view | raw | blame | history
src/utils/mapToolTip/prompt.js 308 ●●●●● patch | view | raw | blame | history
src/views/airspace/airspaceEntering.vue 26 ●●●●● patch | view | raw | blame | history
src/views/airspace/components/AirspaceMap.vue 176 ●●●● patch | view | raw | blame | history
src/assets/images/mapEdit/newcon_box.png
src/styles/element-ui.scss
@@ -160,6 +160,10 @@
    .el-textarea__inner {
      resize: none !important;
    }
    .el-form-item {
      margin-bottom: 8px !important;
    }
  }
}
src/utils/drawPolygon/drawPolygon.css
New file
@@ -0,0 +1,28 @@
.planar-polygon-edit-tooltip {
  position: absolute;
  top: 0px;
  left: 0px;
}
.planar-polygon-edit-tooltip .planar-polygon-edit-menu {
  padding: 4px 0px;
  transform: translate(24px, 10px);
  background: url('@/assets/images/mapEdit/newcon_box.png') no-repeat center / 100% 100%;
  border-radius: 4px;
}
.planar-polygon-edit-tooltip .planar-polygon-edit-menu div {
  color: #fff;
  width: 90px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-weight: bold;
  font-size: 12px;
}
.planar-polygon-edit-tooltip .planar-polygon-edit-menu div:hover {
  background-color: rgba(0, 0, 0, 0.3);
}
src/utils/drawPolygon/drawPolygon.js
New file
@@ -0,0 +1,613 @@
import * as Cesium from 'cesium'
import _, { cloneDeep, throttle } from 'lodash'
import * as turf from '@turf/turf'
import '@/utils/drawPolygon/drawPolygon.css'
import '@/utils/mapToolTip/prompt.css'
import Prompt from '@/utils/mapToolTip/prompt'
/**
 * 盒子缩放通用方法
 * @param data 数据源 格式 [[lng, lat], [lng, lat], [lng, lat]]
 * @param multiple 缩放倍数
 */
const boxTransformScale = (data, multiple = 3) => {
    const line = turf.lineString(data)
    const bbox = turf.bbox(line)
    const bboxPolygon = turf.bboxPolygon(bbox)
    const scaledPolygon = turf.transformScale(bboxPolygon, multiple)
    return turf.bbox(scaledPolygon)
}
/**
 * 多边形绘制与编辑工具类
 * 功能:
 *  - 绘制多边形
 *  - 拖动编辑端点
 *  - 删除端点、删除整个多边形
 *  - 多边形自交检查(避免非法几何)
 *  - 外部订阅/通知机制
 */
export class DrawPolygon {
    constructor() {
        // Cesium 视图对象
        this.viewer = null
        // 当前绘制的多边形
        this.curPolygon = null
        // 绘制模式标识
        this.drawingMode = false
        // 编辑模式标识
        this.editingMode = false
        // 是否正在拖拽端点
        this.isDragging = false
        // 当前拖拽的点实体
        this.draggedEntity = null
        // 多边形实体
        this.polygonEntity = null
        // 存储端点的 DataSource
        this.editPolygonDataSource = null
        this.editPolygonPointDataSource = null
        // 鼠标事件处理器
        this.handler = null
        // 右键菜单 DOM
        this.menuPopup = null
        // 被右键选中的点
        this.delPolygonPoint = null
        // 是否显示警告提示(自交)
        this.isShowWaringTip = false
        // 当前拖拽点是否合法
        this.currentDragPointIsValid = false
        // 当前拖拽点的坐标
        this.currentDragPointPosition = null
        // 事件回调函数绑定 this
        this.handleLeftDown = this.handleLeftDown.bind(this)
        this.handleLeftUp = this.handleLeftUp.bind(this)
        this.handleMouseMove = this.handleMouseMove.bind(this)
        this.handleLeftClick = this.handleLeftClick.bind(this)
        this.handleRightClick = this.handleRightClick.bind(this)
        this.delPolygon = this.delPolygon.bind(this)
        this.delPoint = this.delPoint.bind(this)
        // 外部订阅者
        this.listeners = []
        // 鼠标提示窗
        this.promptStyle = {
            show: true,
            offset: {
                x: 20,
                y: 0
            }
        }
        this.prompt = undefined
    }
    // 实体命名常量
    static ENTITY_NAMES = {
        POLYGON: '区域-面',
        POINT: '区域-端点',
        POLYGON_ID: 'planar-route-polygon',
        POINT_ID_PREFIX: 'planar-route-point'
    };
    // 颜色常量
    static COLORS = {
        DEFAULT_POLYGON: Cesium.Color.fromBytes(45, 140, 240, 99), // 默认面颜色
        DEFAULT_LINE: Cesium.Color.fromBytes(45, 140, 240, 255),  // 默认边界线颜色
        ERROR_POLYGON: Cesium.Color.fromCssColorString('rgba(255, 0, 0, .3)'), // 错误(自交)面颜色
        ERROR_LINE: Cesium.Color.fromCssColorString('rgba(255, 0, 0, 1)')      // 错误(自交)线颜色
    };
    // ============ 发布订阅机制 ============
    // 外部订阅数据变化
    subscribe (key, listener) {
        this.listeners.push({ key, listener })
    }
    // 通知订阅者
    notify (key, data) {
        this.listeners
            .filter(subscriber => subscriber.key === key)
            .forEach(subscriber => subscriber.listener(data))
    }
    // ============ 绘制相关 ============
    // 开始绘制
    startDrawing () {
        this.drawingMode = true
        this.curPolygon = new Cesium.PolygonHierarchy()
        if (!this.prompt && this.promptStyle.show) this.prompt = new Prompt(this.viewer, this.promptStyle)
        // 如果还没有 DataSource,就新建一个
        if (!this.editPolygonDataSource) {
            this.editPolygonDataSource = new Cesium.CustomDataSource('editPolygonDataSource')
            this.viewer?.dataSources.add(this.editPolygonDataSource)
        }
        if (!this.editPolygonPointDataSource) {
            this.editPolygonPointDataSource = new Cesium.CustomDataSource('editPolygonPointDataSource')
            this.viewer?.dataSources.add(this.editPolygonPointDataSource)
        }
        // 清空之前的点
        this.editPolygonDataSource?.entities.removeAll()
        this.editPolygonPointDataSource?.entities.removeAll()
    }
    // 创建多边形实体(含边界线)
    createPolygonEntity () {
        this.polygonEntity = this.editPolygonDataSource.entities.add({
            name: DrawPolygon.ENTITY_NAMES.POLYGON,
            id: DrawPolygon.ENTITY_NAMES.POLYGON_ID,
            polygon: {
                hierarchy: new Cesium.CallbackProperty(() => this.curPolygon, false),
                material: DrawPolygon.COLORS.DEFAULT_POLYGON,
                outline: false,
                outlineWidth: 2,
                heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
            },
            polyline: {
                width: 2,
                material: DrawPolygon.COLORS.DEFAULT_LINE,
                clampToGround: true,
                positions: new Cesium.CallbackProperty(
                    () => [...this.curPolygon.positions, this.curPolygon.positions[0]], // 闭合线
                    false
                )
            },
        })
    }
    // 创建端点实体
    createPointEntity (position, isAdd) {
        const pointIndex = isAdd ? this.curPolygon.positions.length - 2 : this.curPolygon.positions.length - 1
        this.editPolygonPointDataSource.entities.add({
            name: DrawPolygon.ENTITY_NAMES.POINT,
            id: `${DrawPolygon.ENTITY_NAMES.POINT_ID_PREFIX}${pointIndex}`,
            position: position.clone(),
            point: {
                pixelSize: 14,
                color: Cesium.Color.WHITE,
                heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
                disableDepthTestDistance: Number.POSITIVE_INFINITY,
            },
            customData: {
                ind: pointIndex, // 索引记录
            },
        })
    }
    // 添加一个点
    addPosition (position, isAdd = true) {
        // 第一个点要重复压入一次,形成动态绘制效果
        if (this.curPolygon.positions.length === 0 && isAdd) {
            this.curPolygon.positions.push(position.clone())
        }
        this.curPolygon.positions.push(position.clone())
        // 如果没有实体则创建
        if (!this.polygonEntity) {
            this.createPolygonEntity()
        }
        // 创建端点实体
        this.createPointEntity(position, isAdd)
    }
    // ============ 鼠标事件 ============
    // 鼠标左键按下(选中端点拖动)
    handleLeftDown (movement) {
        if (!this.editingMode) return
        const pickedEntity = this.viewer.scene.pick(movement.position)?.id
        const isPoint = pickedEntity?.name === DrawPolygon.ENTITY_NAMES.POINT
        if (pickedEntity && isPoint) {
            this.isDragging = true
            this.draggedEntity = pickedEntity
            this.currentDragPointPosition = this.curPolygon.positions[this.draggedEntity?.customData.ind]
            this.disableMapControl() // 禁止地图交互
        }
    }
    // 鼠标左键抬起(拖拽结束)
    handleLeftUp () {
        if (!(this.editingMode && this.curPolygon?.positions && this.draggedEntity)) return
        if (this.currentDragPointIsValid && this.isDragging) {
            // 更新点位置
            this.draggedEntity.position = this.currentDragPointPosition
            this.curPolygon.positions[this.draggedEntity?.customData.ind] = this.currentDragPointPosition
            // 校验多边形是否自交
            if (!this.curDragPointIsValid(this.curPolygon.positions)) {
                this.isShowWaringTip = true
                this.currentDragPointIsValid = true
                this.updatePolygonAppearance(DrawPolygon.COLORS.ERROR_POLYGON, DrawPolygon.COLORS.ERROR_LINE)
            } else {
                this.isShowWaringTip = false
                this.currentDragPointIsValid = false
                this.updatePolygonAppearance(DrawPolygon.COLORS.DEFAULT_POLYGON, DrawPolygon.COLORS.DEFAULT_LINE)
            }
            this.notify('getShowWaringTip', this.isShowWaringTip)
        }
        // 拖拽结束,恢复交互
        if (this.isDragging) {
            this.notify('getPolygonPositions', this.curPolygon.positions)
            this.isDragging = false
            this.draggedEntity = null
            this.enableMapControl()
        }
    }
    // 鼠标移动
    handleMouseMove (movement) {
        if (!this.drawingMode && !this.editingMode) {
            if (this.curPolygon.positions.length > 3) {
                this.prompt.update(movement.endPosition, "单击面开启编辑")
            }
            return
        }
        const cartesian = this.viewer.scene.pickPosition(movement.endPosition)
        if (!cartesian) return
        if (this.drawingMode) {
            if (this.curPolygon.positions.length < 1) {
                this.prompt.update(movement.endPosition, "单击开始绘制")
            } else {
                this.prompt.update(movement.endPosition, "单击增加点,双击结束绘制")
            }
        }
        if (this.editingMode) {
            this.prompt.update(movement.endPosition, "拖拽端点更新位置,右击进行删除")
        }
        // 编辑模式下,拖拽点实时更新
        if (this.editingMode && this.draggedEntity?.customData) {
            this.draggedEntity.position = cartesian
            this.curPolygon.positions[this.draggedEntity?.customData.ind] = cartesian
        }
        // 绘制模式下,实时更新最后一个点
        if (this.drawingMode && this.polygonEntity && this.curPolygon.positions.length >= 1) {
            this.curPolygon.positions.pop()
            this.curPolygon.positions.push(cartesian)
        }
        // 实时检查是否自交
        if ((this.editingMode && this.draggedEntity?.customData) || (this.drawingMode && this.polygonEntity && this.curPolygon.positions.length >= 3)) {
            if (!this.curDragPointIsValid(this.curPolygon.positions)) {
                this.isShowWaringTip = true
                this.currentDragPointIsValid = true
                this.updatePolygonAppearance(DrawPolygon.COLORS.ERROR_POLYGON, DrawPolygon.COLORS.ERROR_LINE)
            } else {
                this.isShowWaringTip = false
                this.currentDragPointIsValid = false
                this.updatePolygonAppearance(DrawPolygon.COLORS.DEFAULT_POLYGON, DrawPolygon.COLORS.DEFAULT_LINE)
            }
            this.notify('getShowWaringTip', this.isShowWaringTip)
        }
    }
    // 鼠标左键点击
    handleLeftClick (click) {
        this.removeMenuPopup()
        this.prompt.showTip()
        const pickedAllEntity = this.viewer.scene.drillPick(click.position).filter(i => i.id)
        const isStartPoint = pickedAllEntity.find(i => i.id.name === '起飞点')
        const isPolygonPoint = pickedAllEntity.find(i => i.id.name === DrawPolygon.ENTITY_NAMES.POINT)
        const isPolygon = pickedAllEntity.find(i => i.id.name === DrawPolygon.ENTITY_NAMES.POLYGON)
        if (isStartPoint) return
        // 如果不是绘制模式
        if (!this.drawingMode) {
            if (!isPolygon) {
                this.editingMode = false
                this.editPolygonPointDataSource.entities.show = false
                this.prompt.update(click.endPosition, "单击面开启编辑")
                return
            }
            if (isPolygon) {
                this.editingMode = true
                this.editPolygonPointDataSource.entities.show = true
                this.prompt.update(click.endPosition, "拖拽端点更新位置,右击进行删除")
            }
            return
        }
        // 点击闭合多边形
        if (this.curPolygon.positions.length < 4 && isPolygonPoint) {
            return
        }
        if (this.curPolygon.positions.length >= 4 && isPolygonPoint) {
            if (!this.drawingMode) return
            this.finishDrawing()
            return
        }
        // 添加新的点
        const cartesian = this.viewer.scene.pickPosition(click.position)
        if (!cartesian) return
        this.addPosition(cartesian)
    }
    // 鼠标右键点击(弹出菜单)
    handleRightClick (click) {
        const that = this
        if (that.drawingMode) return
        this.prompt.hideTip()
        that.removeMenuPopup()
        const pickedAllEntity = that.viewer.scene.drillPick(click.position).filter(i => i.id)
        const isPolygon = pickedAllEntity.find(i => i.id.name === DrawPolygon.ENTITY_NAMES.POLYGON)
        const isEditPoint = pickedAllEntity.find(i => i.id.name === DrawPolygon.ENTITY_NAMES.POINT)
        const { position: { x, y } } = click
        let pickedEntity, tooltipEvent, menuType
        if (isPolygon) {
            pickedEntity = isPolygon
            tooltipEvent = that.delPolygon
            menuType = 'polygon'
        } else if (isEditPoint) {
            pickedEntity = isEditPoint
            tooltipEvent = that.delPoint
            menuType = 'edit-point'
        }
        if (pickedEntity) {
            that.delPolygonPoint = pickedEntity.id
            that.menuPopup = that.createMenuPopup(menuType)
            that.menuPopup.style.transform = `translate3d(${x - 10}px, ${y - 10}px, 0)`
            that.viewer.container.appendChild(that.menuPopup)
            that.menuPopup.addEventListener('click', tooltipEvent)
        }
    }
    // ============ 删除相关 ============
    // 删除所有实体
    removeEntities () {
        if (this.editPolygonDataSource) {
            this.editPolygonDataSource.entities.removeAll()
            this.editPolygonDataSource = null
        }
        if (this.editPolygonPointDataSource) {
            this.editPolygonPointDataSource.entities.removeAll()
            this.editPolygonPointDataSource = null
        }
        this.editingMode = false
        this.polygonEntity = null
        this.curPolygon = null
    }
    // 完成绘制
    finishDrawing () {
        this.curPolygon.positions.pop()
        if (this.curPolygon.positions.length >= 3) {
            this.drawingMode = false
            this.editingMode = true
            this.editPolygonPointDataSource.entities.show = true
            if (!this.curDragPointIsValid(this.curPolygon.positions)) {
                this.isShowWaringTip = true
                this.currentDragPointIsValid = true
                this.updatePolygonAppearance(DrawPolygon.COLORS.ERROR_POLYGON, DrawPolygon.COLORS.ERROR_LINE)
            } else {
                this.isShowWaringTip = false
                this.currentDragPointIsValid = false
                this.updatePolygonAppearance(DrawPolygon.COLORS.DEFAULT_POLYGON, DrawPolygon.COLORS.DEFAULT_LINE)
            }
            this.notify('getShowWaringTip', this.isShowWaringTip)
            this.notify('getPolygonPositions', this.curPolygon.positions)
        }
    }
    // 删除多边形
    delPolygon () {
        this.removeEntities()
        this.removeMenuPopup()
        this.notify('getPolygonPositions', [])
        this.startDrawing()
        this.prompt.showTip()
    }
    // 删除端点
    delPoint () {
        if (this.curPolygon.positions.length <= 3) {
            this.removeMenuPopup()
            return ElMessage.warning('端点不可少于3个')
        }
        if (!this.delPolygonPoint) return
        this.curPolygon.positions.splice(this.delPolygonPoint.customData.ind, 1)
        this.editPolygonPointDataSource.entities.remove(this.delPolygonPoint)
        this.removeMenuPopup()
        // 更新剩余点索引
        this.editPolygonPointDataSource.entities.values.forEach((item, index) => {
            item.customData.ind = index
        })
        this.notify('getPolygonPositions', this.curPolygon.positions)
    }
    // ============ 工具方法 ============
    // 创建右键菜单
    createMenuPopup (type = 'polygon') {
        const menuPopupVBox = document.createElement('div')
        menuPopupVBox.id = 'planarPolygonEdit'
        menuPopupVBox.className = 'planar-polygon-edit-tooltip'
        const menuPopup = document.createElement('div')
        menuPopup.id = 'planarPolygonEditMenu'
        menuPopup.className = 'planar-polygon-edit-menu'
        const menuItems = type === 'polygon'
            ? [{ title: '删除测区', class: 'del-planar-polygon' }]
            : [{ title: '删除端点', class: 'del-planar-point' }]
        menuItems.forEach(item => {
            const titleDiv = document.createElement('div')
            titleDiv.innerText = item.title
            titleDiv.className = item.class
            menuPopup.appendChild(titleDiv)
        })
        menuPopupVBox.appendChild(menuPopup)
        return menuPopupVBox
    }
    // 移除菜单
    removeMenuPopup () {
        const that = this
        if (that.menuPopup) {
            that.menuPopup.removeEventListener('click', that.delPolygon)
            that.menuPopup.removeEventListener('click', that.delPoint)
            that.viewer.container.removeChild(that.menuPopup)
            that.menuPopup = null
        }
        that.delPolygonPoint = null
    }
    // 更新多边形样式(正常/错误)
    updatePolygonAppearance (polygonColor, lineColor) {
        this.polygonEntity.polygon.material = polygonColor
        this.polygonEntity.polyline.material = lineColor
    }
    // 禁用地图交互
    disableMapControl () {
        const controller = this.viewer.scene.screenSpaceCameraController
        controller.enableRotate = false
        controller.enableTranslate = false
        controller.enableZoom = false
    }
    // 启用地图交互
    enableMapControl () {
        const controller = this.viewer.scene.screenSpaceCameraController
        controller.enableRotate = true
        controller.enableTranslate = true
        controller.enableZoom = true
    }
    // 检查多边形是否自交
    curDragPointIsValid (positions) {
        if (positions.length < 3) return true
        const cartographics = Cesium.Ellipsoid.WGS84.cartesianArrayToCartographicArray(positions)
        const latLngPoints = cartographics.map(cartographic => [
            Cesium.Math.toDegrees(cartographic.longitude),
            Cesium.Math.toDegrees(cartographic.latitude)
        ])
        // 用 turf.js 检查自交
        const poly = turf.polygon([[...latLngPoints, latLngPoints[0]]])
        const intersections = turf.kinks(poly)
        return intersections.features.length === 0
    }
    // 初始化已有多边形
    initPolygon (viewer, positions) {
        this.initHandler(viewer)
        this.startDrawing()
        positions.forEach(item => {
            this.addPosition(
                Cesium.Cartesian3.fromDegrees(Number(item.lng), Number(item.lat), Number(item.height)),
                false
            )
        })
        // 视角飞入区域
        const newBox = boxTransformScale(positions.map(item => [item.lng, item.lat]), 5)
        viewer.camera.flyTo({
            destination: Cesium.Rectangle.fromDegrees(...newBox),
            offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-90), 0),
            duration: 0.5,
        })
        this.drawingMode = false
        this.editingMode = true
    }
    // 初始化事件处理器
    initHandler (viewer) {
        this.viewer = viewer
        this.startDrawing()
        if (!this.handler) {
            this.handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
            // 注册鼠标事件
            const events = [
                [Cesium.ScreenSpaceEventType.LEFT_DOWN, this.handleLeftDown],
                [Cesium.ScreenSpaceEventType.LEFT_UP, this.handleLeftUp],
                [Cesium.ScreenSpaceEventType.MOUSE_MOVE, this.handleMouseMove],
                [Cesium.ScreenSpaceEventType.LEFT_CLICK, this.handleLeftClick],
                [Cesium.ScreenSpaceEventType.RIGHT_CLICK, this.handleRightClick]
            ]
            events.forEach(([type, handler]) => {
                this.handler.setInputAction(handler, type)
            })
        }
    }
    // 移除事件处理器
    removeHandler () {
        if (this.handler) {
            const eventTypes = [
                Cesium.ScreenSpaceEventType.LEFT_DOWN,
                Cesium.ScreenSpaceEventType.LEFT_UP,
                Cesium.ScreenSpaceEventType.MOUSE_MOVE,
                Cesium.ScreenSpaceEventType.LEFT_CLICK,
                Cesium.ScreenSpaceEventType.RIGHT_CLICK
            ]
            eventTypes.forEach(type => {
                this.handler.removeInputAction(type)
            })
            this.handler = null
        }
    }
    /**
     * 销毁实例,释放资源
     */
    destroy () {
        if (!this.viewer) return
        this.removeMenuPopup()
        this.removeEntities()
        this.removeHandler()
        this.enableMapControl()
    }
}
src/utils/mapToolTip/prompt.css
New file
@@ -0,0 +1,56 @@
.easy3d-prompt {
  position: absolute;
  top: -9999px;
  left: -9999px;
  pointer-events: none;
}
.prompt-close {
  position: absolute;
  top: 0;
  right: 0;
  padding: 4px 4px 0 0;
  border: none;
  text-align: center;
  width: 18px;
  height: 14px;
  font: 16px/14px Tahoma, Verdana, sans-serif;
  color: #c3c3c3;
  text-decoration: none;
  font-weight: bold;
  background: transparent;
}
.prompt-content-container {
  /* 不给padding撑不开div */
  max-width: 400px;
  border-radius: 4px;
  padding: 1px;
  background: white;
  color: #333;
  box-shadow: 0 3px 14px rgb(0 0 0 / 40%);
}
.prompt-content {
  margin: 13px 19px;
}
.prompt-anchor-container {
  position: absolute;
  width: 40px;
  height: 20px;
  left: 50%;
  margin-left: -20px;
  overflow: hidden;
  pointer-events: none;
}
.prompt-anchor {
  margin: -10px auto 0;
  background: white;
  width: 17px;
  height: 17px;
  -webkit-transform: rotate(45deg);
  transform: rotate(45deg);
}
src/utils/mapToolTip/prompt.js
New file
@@ -0,0 +1,308 @@
/**
 * 气泡窗类
 * @class
 *
*/
import * as Cesium from 'cesium'
import "./prompt.css"
class Prompt {
    /**
     * @param {Cesium.Viewer} viewer 地图viewer对象
     * @param {Object} opt
     * @param {Cesium.Cartesian3 | Array} [opt.position] 弹窗坐标 (type=2时生效)
     * @param {Boolean} opt.show 是否显示
     * @param {Function} [opt.success] 创建成功的回调函数
     * @param {Number} [opt.type=1] 1~位置变化提示框 / 2~固定坐标提示框
     * @param {Cesium.Cartesian3 | Array} opt.position 固定坐标提示框的坐标( cartesian3 / [101,30] ),type为1时,可不设置此参数
     * @param {Boolean} [opt.anchor=true] 是否显示锚点
     * @param {Boolean} [opt.closeBtn=true] 是否显示关闭按钮
     * @param {String} opt.className 自定义class
     * @param {String} opt.content 弹窗内容
     * @param {Function} [opt.close] 关闭弹窗时的回调函数
     * @param {Object} [opt.offset] 偏移参数
     * @param {Number} [opt.offset.x] 横坐标偏移像素单位
     * @param {Number} [opt.offset.y] 纵坐标偏移像素单位
     * @param {Object} [opt.style] 弹窗面板样式
     * @param {String} [opt.style.background='white'] 背景色
     * @param {String} [opt.style.boxShadow] 弹窗阴影(css属性)
     * @param {String} [opt.style.color] 弹窗颜色
     *
     */
    constructor(viewer, opt) {
        this.viewer = viewer
        if (!this.viewer) return
        this.type = "prompt"
        // 默认值
        opt = opt || {}
        const promptType = opt.type == undefined ? 1 : opt.type
        let defaultOpt = {
            id: (new Date().getTime() + "" + Math.floor(Math.random() * 10000)),
            type: promptType,
            anchor: promptType == 2 ? true : false,
            closeBtn: promptType == 2 ? true : false,
            offset: promptType == 2 ? { x: 0, y: -20 } : { x: 10, y: 10 },
            content: "",
            show: true,
            style: {
                background: "rgba(0,0,0,0.5)",
                color: "white"
            }
        }
        this.opt = Object.assign(defaultOpt, opt)
        /**
         * @property {Object} attr 相关属性
         */
        this.attr = this.opt
        // ====================== 创建弹窗内容 start ======================
        const mapid = this.viewer.container.id
        /**
         * @property {Boolearn} isShow 当前显示状态
         */
        this.isShow = this.opt.show == undefined ? true : this.opt.show // 是否显示
        let anchorHtml = ``
        let closeHtml = ``
        const background = this.opt.style.background
        const color = this.opt.style.color
        if (this.opt.anchor) {
            anchorHtml += `
            <div class="prompt-anchor-container">
                <div class="prompt-anchor" style="background:${background} !important;">
                </div>
            </div>
            `
        }
        if (this.opt.closeBtn) { // 移动提示框 不显示关闭按钮
            closeHtml = `<a class="prompt-close" attr="${this.opt.id}" id="prompt-close-${this.opt.id}">x</a>`
        }
        let boxShadow = this.opt.style.boxShadow
        const promptId = "prompt-" + this.opt.id
        const promptConenet = `
                <!-- 文本内容 -->
                <div class="prompt-content-container" style="background:${background} !important;color:${color} !important;box-shadow:${boxShadow} !important">
                    <div class="prompt-content" id="prompt-content-${this.opt.id}">
                        ${this.opt.content}
                    </div>
                </div>
                <!-- 锚 -->
                ${anchorHtml}
                <!-- 关闭按钮 -->
                ${closeHtml}
        `
        // 构建弹窗元素
        this.promptDiv = window.document.createElement("div")
        this.promptDiv.className = `easy3d-prompt ${this.opt.className}`
        this.promptDiv.id = promptId
        this.promptDiv.innerHTML = promptConenet
        let mapDom = window.document.getElementById(mapid)
        mapDom.appendChild(this.promptDiv)
        const clsBtn = window.document.getElementById(`prompt-close-${this.opt.id}`)
        let that = this
        if (clsBtn) {
            clsBtn.addEventListener("click", (e) => {
                that.hide()
                if (that.opt.close) that.opt.close()
            })
        }
        /**
         * @property {Object} promptDom 弹窗div
         */
        this.promptDom = window.document.getElementById(promptId)
        this.position = this.transPosition(this.opt.position)
        // ====================== 创建弹窗内容 end ======================
        if (promptType == 2) this.bindRender() // 固定位置弹窗 绑定实时渲染 当到地球背面时 隐藏
        if (this.opt.show == false) this.hide()
        this.containerW = this.viewer.container.offsetWidth
        this.containerH = this.viewer.container.offsetHeight
        this.containerLeft = this.viewer.container.offsetLeft
        this.containerTop = this.viewer.container.offsetTop
        /**
        * @property {Number} contentW 弹窗宽度
        */
        this.contentW = Math.ceil(Number(this.promptDom.offsetWidth)) // 宽度
        /**
         * @property {Number} contentH 弹窗高度
         */
        this.contentH = this.promptDom.offsetHeight // 高度
        if (this.opt.success) this.opt.success()
    }
    /**
     * 销毁
     */
    destroy () {
        if (this.promptDiv) {
            window.document.getElementById(this.viewer.container.id).removeChild(this.promptDiv)
            this.promptDiv = null
        }
        if (this.rendHandler) {
            this.rendHandler()
            this.rendHandler = null
        }
    }
    // 实时监听
    bindRender () {
        let that = this
        this.rendHandler = this.viewer.scene.postRender.addEventListener(function () {
            if (!that.isShow && that.promptDom) {
                that.promptDom.style.display = "none"
                return
            }
            if (!that.position) return
            if (that.position instanceof Cesium.Cartesian3) {
                let px = Cesium.SceneTransforms.wgs84ToWindowCoordinates(that.viewer.scene, that.position)
                if (!px) return
                const occluder = new Cesium.EllipsoidalOccluder(that.viewer.scene.globe.ellipsoid, that.viewer.scene.camera.position)
                // 当前点位是否可见 是否在地球背面
                const res = occluder.isPointVisible(that.position)
                if (res) {
                    if (that.promptDom) that.promptDom.style.display = "block"
                } else {
                    if (that.promptDom) that.promptDom.style.display = "none"
                }
                that.setByPX({
                    x: px.x,
                    y: px.y
                })
            } else {
                that.setByPX({
                    x: that.position.x,
                    y: that.position.y
                })
            }
        }, this)
    }
    /**
     *
     * @param {Cesium.Cartesian3 | Object} px 弹窗坐标
     * @param {String} html 弹窗内容
     */
    update (px, html) {
        if (px instanceof Cesium.Cartesian3) {
            this.position = px.clone()
            px = Cesium.SceneTransforms.wgs84ToWindowCoordinates(this.viewer.scene, px)
        }
        this.contentW = Math.ceil(Number(this.promptDom.offsetWidth)) // 宽度
        this.contentH = this.promptDom.offsetHeight // 高度
        if (px) this.setByPX(px)
        if (html) this.setContent(html)
    }
    // 判断是否在当前视野内
    isInView () {
        if (!this.position) return false
        let px = null
        if (this.position instanceof Cesium.Cartesian2) {
            px = this.position
        } else {
            px = Cesium.SceneTransforms.wgs84ToWindowCoordinates(this.viewer.scene, this.position)
        }
        const occluder = new Cesium.EllipsoidalOccluder(this.viewer.scene.globe.ellipsoid, this.viewer.scene.camera.position)
        // 是否在地球背面
        const res = occluder.isPointVisible(this.position)
        let isin = false
        if (!px) return isin
        if (
            px.x > this.containerLeft &&
            px.x < (this.containerLeft + this.containerW) &&
            px.y > this.containerTop &&
            px.y < (this.containerTop + this.containerH)
        ) {
            isin = true
        }
        return res && isin
    }
    /**
     * 是否可见
     * @param {Boolean} isShow true可见,false不可见
     */
    setVisible (isShow) {
        let isin = this.isInView(this.position)
        if (isin && isShow) {
            this.isShow = true
            if (this.promptDom) this.promptDom.style.display = "block"
        } else {
            this.isShow = false
            if (this.promptDom) this.promptDom.style.display = "none"
        }
    }
    showTip () {
        if (this.promptDom) this.promptDom.style.display = "block"
    }
    hideTip () {
        if (this.promptDom) this.promptDom.style.display = "none"
    }
    /**
     * 显示
     */
    show () {
        this.setVisible(true)
    }
    /**
     * 隐藏
     */
    hide () {
        this.setVisible(false)
    }
    /**
     * 设置弹窗内容
     * @param {String} content 内容
     */
    setContent (content) {
        let pc = window.document.getElementById(`prompt-content-${this.opt.id}`)
        pc.innerHTML = content
    }
    /**
     * 设置弹窗坐标
     * @param {Object} opt 屏幕坐标
     */
    setByPX (opt) {
        if (!opt) return
        if (this.promptDom) {
            const contentW = this.promptDom.offsetWidth // 宽度
            const contentH = this.promptDom.offsetHeight // 高度
            if (this.opt.type == 1) {
                this.promptDom.style.left = ((Number(opt.x) + Number(this.opt.offset.x || 0))) + "px"
                this.promptDom.style.top = ((Number(opt.y) + Number(this.opt.offset.y || 0))) + "px"
            } else {
                this.promptDom.style.left = ((Number(opt.x) + Number(this.opt.offset.x || 0)) - Number(this.contentW) / 2) + "px"
                this.promptDom.style.top = ((Number(opt.y) + Number(this.opt.offset.y || 0)) - Number(this.contentH)) + "px"
            }
        }
    }
    // 坐标转换
    transPosition (p) {
        let position
        if (Array.isArray(p)) {
            const posi = Cesium.Cartesian3.fromDegrees(p[0], p[1], p[2] || 0)
            position = posi.clone()
        } else if (p instanceof Cesium.Cartesian3) {
            position = p.clone()
        } else { // 像素类型
            position = p
        }
        return position
    }
}
export default Prompt
src/views/airspace/airspaceEntering.vue
@@ -17,7 +17,7 @@
    </avue-crud>
  </basic-container>
  <AirspaceMap v-model:show="airspaceMapShow" />
  <AirspaceMap v-model:show="airspaceMapShow" @submitClick="searchReset" />
</template>
<script setup>
@@ -43,7 +43,7 @@
const creatorOption = ref([])
const loading = ref(true)
const page = ref({
  pageSize: 20,
  pageSize: 10,
  currentPage: 1,
  total: 0,
  lotValue: '',
@@ -55,7 +55,7 @@
  addBtn: false,
  tip: false,
  searchShow: true,
  searchMenuSpan: 12,
  searchMenuSpan: 8,
  searchMenuPosition: 'right',
  border: true,
  index: true,
@@ -85,6 +85,12 @@
    {
      label: '类型',
      prop: 'patches_type',
      type: 'select',
      dicUrl: `/blade-system/dict/dictionary?code=flow`,
      props: {
        label: 'dictValue',
        value: 'dictKey',
      },
      search: true,
      searchSpan: 4,
      searchLabelWidth: 54,
@@ -102,10 +108,15 @@
    },
    {
      label: '管控时段',
      prop: 'patches_type',
      prop: 'daterange',
      type: 'daterange',
      format: 'YYYY-MM-DD',
      valueFormat: 'YYYY-MM-DD',
      startPlaceholder: '开始日期',
      endPlaceholder: '结束日期',
      search: true,
      searchSpan: 4,
      rules: [{ required: true, message: '请输入管控时间', trigger: 'blur' }],
      searchSpan: 8,
      searchRange: true,
    },
    {
      label: '创建人',
@@ -150,7 +161,6 @@
    })
  }
)
// ---------------- computed ----------------
const permission = computed(() => store.getters.permission)
const permissionList = computed(() => ({
@@ -220,7 +230,7 @@
  page.value.userName = ''
  page.value.lotValue = ''
  page.value.currentPage = 1
  page.value.pageSize = 20
  page.value.pageSize = 10
  onLoad(page.value)
}
src/views/airspace/components/AirspaceMap.vue
@@ -5,16 +5,35 @@
            <div class="form-container">
                <avue-form ref="formEle" :option="option" v-model="form"></avue-form>
            </div>
            <div class="map-container">
                <div class="tool-tip warning" v-show="isShowWaringTip">
                    <span class="icon">
                        <el-icon>
                            <WarningFilled />
                        </el-icon>
                    </span>
                    <span>空域不支持交叉面,无法生成空域</span>
                </div>
                <div id="AirspaceMap" class="ztzf-cesium"></div>
            </div>
            <div class="btn-container">
                <el-button icon="el-icon-check" type="primary" @click="handleSubmit">确定</el-button>
                <el-button icon="el-icon-close" @click="dialogShow = false">取消</el-button>
            </div>
        </div>
    </el-dialog>
</template>
<script setup>
import _, { cloneDeep, throttle } from 'lodash'
import * as Cesium from 'cesium'
import * as turf from '@turf/turf'
import { DrawPolygon } from '@/utils/drawPolygon/drawPolygon'
import { PublicCesium } from '@/utils/cesium/publicCesium'
import { ElMessageBox, ElMessage } from 'element-plus'
const props = defineProps({
    title: {
@@ -23,6 +42,8 @@
    },
})
const emit = defineEmits(['submitClick'])
const dialogShow = defineModel('show')
const formEle = ref(null)
@@ -30,10 +51,11 @@
const option = ref({
    submitBtn: false,
    emptyBtn: false,
    menuBtn: false,
    column: [
        {
            label: '名称',
            prop: 'input',
            prop: 'name',
            type: 'input',
            rules: [
                {
@@ -46,7 +68,7 @@
        {
            label: '高度',
            prop: 'input',
            prop: 'height',
            type: 'input',
            rules: [
                {
@@ -59,18 +81,13 @@
        {
            label: '类型',
            prop: 'select',
            prop: 'type',
            type: 'select',
            dicData: [
                {
                    label: '字典1',
                    value: 0,
                },
                {
                    label: '字典2',
                    value: 1,
                },
            ],
            dicUrl: `/blade-system/dict/dictionary?code=flow`,
            props: {
                label: 'dictValue',
                value: 'dictKey',
            },
            rules: [
                {
                    required: true,
@@ -97,21 +114,26 @@
        },
        {
            label: '备注',
            prop: 'input',
            label: '管控原因',
            prop: 'remark',
            type: 'textarea',
            span: 24,
            rows: 1,
            minRows: 1,
            maxRows: 1,
            placeholder: '请输入备注',
            placeholder: '请输入管控原因',
        },
    ],
})
let drawPolygonExample = null
let publicCesiumInstance = null
let viewer = null
// 地图
const isShowWaringTip = ref(false)
const curDrawPolygonData = ref([])
const initMap = () => {
    if (!document.getElementById('AirspaceMap')) {
        return
@@ -121,37 +143,155 @@
        flatMode: false,
        terrain: true,
        layerMode: 4,
        contour: false,
        contour: true,
        flyToContour: true,
    })
    viewer = publicCesiumInstance.getViewer()
    drawPolygonExample = new DrawPolygon()
    drawPolygonExample.initHandler(viewer)
    drawPolygonExample?.subscribe('getShowWaringTip', data => {
        isShowWaringTip.value = data
    })
    drawPolygonExample?.subscribe('getPolygonPositions', data => {
        curDrawPolygonData.value = data.map(item => {
            let cartographic = Cesium.Cartographic.fromCartesian(item)
            let lng = Cesium.Math.toDegrees(cartographic.longitude) // 经度
            let lat = Cesium.Math.toDegrees(cartographic.latitude) // 纬度
            return {
                lng: _.round(lng, 6),
                lat: _.round(lat, 6),
            }
        })
    })
}
watch(dialogShow, async (show) => {
    if (show) {
        await nextTick()
        cesiumContextMenu()
        initMap()
    }
})
const handleSubmit = () => {
    if (ElMessage.value) {
        ElMessage.warning('空域不支持交叉,请重新绘制或调整')
        return
    }
    if (curDrawPolygonData.value.length === 0) {
        ElMessage.warning('请绘制空域区域')
        return
    }
    if (formEle.value) {
        formEle.value.validate((valid, done, msg) => {
            if (valid) {
                let disposePolygon = curDrawPolygonData.value.map(i => [i.lng, i.lat])
                let polygon = turf.polygon([
                    [
                        ...disposePolygon,
                        disposePolygon[0]
                    ]
                ])
                let area = turf.area(polygon)
                emit('submitClick')
                done()
            } else {
                return false
            }
        })
    }
}
const handleClose = () => {
    isShowWaringTip.value = false
    curDrawPolygonData.value = []
    cesiumContextMenu(false)
    drawPolygonExample?.destroy()
    publicCesiumInstance?.viewerDestroy()
    publicCesiumInstance = null
    viewer = null
    drawPolygonExample = null
    form.value = {}
    formEle.value.resetForm()
    dialogShow.value = false
}
// 阻止右键默认行为
const preventDefault = event => {
    event.preventDefault()
    return
}
const cesiumContextMenu = (isAdd = true) => {
    let cesium = document.getElementById('AirspaceMap')
    if (!cesium) return
    if (isAdd) {
        cesium.addEventListener('contextmenu', preventDefault)
    } else {
        cesium.removeEventListener('contextmenu', preventDefault)
    }
}
</script>
<style scoped lang="scss">
.custom-container {
    .map-container {
        height: 440px;
        height: 620px;
        #AirspaceMap {
            position: relative;
            width: 100%;
            height: 100%;
            overflow: hidden;
        }
    }
    .btn-container {
        margin-top: 16px;
        display: flex;
        align-items: center;
        justify-content: center;
    }
}
.tool-tip {
    display: flex;
    justify-content: center;
    align-items: center;
    position: absolute;
    width: 800px;
    height: 64px;
    font-family: Source Han Sans CN, Source Han Sans CN;
    font-size: 18px;
    color: #ffffff;
    background: rgba(11, 31, 58, 0.7);
    -moz-user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    user-select: none;
    z-index: 9;
}
.warning {
    top: 234px;
    left: 50%;
    transform: translateX(-50%);
    color: #fff;
    background: rgba(140, 0, 0, 0.4);
    .icon {
        display: flex;
        align-items: center;
        color: #ff1f1f;
    }
}
</style>