吉安感知网项目-前端
chenyao
2026-01-30 fb39a7bb69cc55d2e1ce61d47e0677aa40540f95
applications/drone-command/src/views/areaManage/partition/FormDiaLog.vue
@@ -1,24 +1,62 @@
<template>
   <el-dialog class="command-page-map-view-dialog" v-model="visible" :show-close="false"
      :close-on-click-modal="false">
   <el-dialog class="command-page-map-view-dialog" v-model="visible" :show-close="false" :close-on-click-modal="false">
      <div class="dialog-container">
         <div class="left-container">
            <CommonCesiumMap
               ref="mapRef"
               class="leftMap command-cesium"
               :active="visible"
               :flat-mode="false"
               :terrain="true"
               :layer-mode="4"
               :boundary="false"
            />
            <CommonCesiumMap ref="mapRef" class="leftMap command-cesium" :active="visible" :flat-mode="false"
               :terrain="true" :layer-mode="4" :boundary="false" />
            <div v-if="showTypePanel && !readonly" class="shape-type-panel">
               <div class="panel-header">
                  <span>{{ drawTypeLabelMap[currentShapeType] || '区域' }}</span>
                  <el-icon class="panel-close" @click.stop="handleTypePanelClose">
                     <Close />
                  </el-icon>
               </div>
               <div class="panel-body">
                  <span class="panel-label">区域类型</span>
                  <el-select class="command-select" popper-class="command-select-popper" v-model="activeAreaType"
                     placeholder="请选择" :disabled="currentShapeType === 'buffer'" collapse-tags :multiple="currentShapeType === 'buffer'"
                     @change="handleAreaTypeChange">
                     <el-option v-for="item in dictObj.areaType" :key="item.dictKey" :label="item.dictValue"
                        :value="item.dictKey" />
                  </el-select>
               </div>
            </div>
            <div class="map-toolbar">
               <el-tooltip content="多边形" placement="left">
                  <button class="tool-btn" type="button" @click="handleDraw('polygon')">
                     <img class="tool-icon-image" :src="polygonIcon" alt="多边形" />
                  </button>
               </el-tooltip>
               <el-tooltip content="矩形" placement="left">
                  <button class="tool-btn" type="button" @click="handleDraw('rectangle')">
                     <img class="tool-icon-image" :src="rectIcon" alt="矩形" />
                  </button>
               </el-tooltip>
               <el-tooltip content="椭圆" placement="left">
                  <button class="tool-btn" type="button" @click="handleDraw('ellipse')">
                     <img class="tool-icon-image" :src="ellipseIcon" alt="椭圆" />
                  </button>
               </el-tooltip>
               <el-tooltip content="缓冲圆" placement="left">
                  <button class="tool-btn" type="button" @click="handleDraw('buffer')">
                     <img class="tool-icon-image" :src="bufferIcon" alt="缓冲圆" />
                  </button>
               </el-tooltip>
               <el-tooltip content="删除" placement="left">
                  <button class="tool-btn danger" type="button" @click="handleClearShape">
                     <img class="tool-icon-image" :src="trashIcon" alt="删除" />
                  </button>
               </el-tooltip>
            </div>
         </div>
         <div class="right-container">
            <div class="header">
               <span>{{ titleEnum[dialogMode] }}</span>
               <el-icon class="close-btn" @click.stop="visible = false"><Close /></el-icon>
               <el-icon class="close-btn" @click.stop="visible = false">
                  <Close />
               </el-icon>
            </div>
            <div class="content" v-if="readonly">
@@ -27,18 +65,6 @@
                  <el-col :span="24">
                     <div class="label">区域名称</div>
                     <div class="val">{{ formData.areaName }}</div>
                  </el-col>
                  <el-col :span="24">
                     <div class="label">区域位置</div>
                     <div class="val">{{ formatLocation(formData) }}</div>
                  </el-col>
                  <el-col :span="24">
                     <div class="label">区域面积</div>
                     <div class="val">{{ formData.areaSize || '-' }}k㎡</div>
                  </el-col>
                  <el-col :span="24">
                     <div class="label">区域类型</div>
                     <div class="val">{{ getDictLabel(formData.areaType, dictObj.areaType) }}</div>
                  </el-col>
                  <el-col :span="24">
                     <div class="label">触发条件</div>
@@ -64,11 +90,29 @@
                  </el-col>
               </el-row>
               <div class="detail-title">绘制区域列表</div>
               <div class="command-table-container">
                  <div class="command-table-content">
                     <el-table class="command-table" :data="shapeList" row-key="id">
                        <el-table-column prop="areaType" label="区域类型">
                           <template v-slot="{ row }">
                              {{ resolveShapeAreaTypeLabel(row) }}
                           </template>
                        </el-table-column>
                        <el-table-column prop="drawType" label="绘制类型">
                           <template v-slot="{ row }">
                              {{ drawTypeLabelMap[row.drawType] || '-' }}
                           </template>
                        </el-table-column>
                     </el-table>
                  </div>
               </div>
               <div class="detail-title">关联设备</div>
               <div class="command-table-container">
                  <div class="command-table-content">
                     <el-table class="command-table" ref="deviceTableRef" :data="deviceOptions"
                        row-key="id">
                     <el-table class="command-table" ref="deviceTableRef" :data="deviceOptions" row-key="id"
                        @row-click="handleDeviceRowClick">
                        <el-table-column prop="deviceName" label="设备名称" />
                        <el-table-column prop="deviceType" label="类型">
                           <template v-slot="{ row }">
@@ -83,25 +127,12 @@
            <el-form class="dialog-form" v-else ref="formRef" :model="formData" :rules="rules" :disabled="readonly"
               label-width="96px">
               <el-form-item label="区域名称" prop="areaName">
                  <el-input class="command-input" v-model="formData.areaName" maxlength="50"
                     placeholder="请输入" clearable />
               </el-form-item>
               <el-form-item label="区域位置" prop="longitude">
                  <div class="val">{{ formatLocation(formData) }}</div>
               </el-form-item>
               <el-form-item label="区域面积" prop="areaSize">
                  <div class="val">{{ formData.areaSize || '-' }}k㎡</div>
               </el-form-item>
               <el-form-item label="区域类型" prop="areaType">
                  <el-select class="command-select" popper-class="command-select-popper"
                     v-model="formData.areaType" placeholder="请选择" clearable>
                     <el-option v-for="item in dictObj.areaType" :key="item.dictKey" :label="item.dictValue"
                        :value="item.dictKey" />
                  </el-select>
                  <el-input class="command-input" v-model="formData.areaName" maxlength="50" placeholder="请输入"
                     clearable />
               </el-form-item>
               <el-form-item label="响应机制" prop="responseMechanism">
                  <el-input class="command-input" v-model="formData.responseMechanism"
                     maxlength="200" placeholder="请输入" clearable />
                  <el-input class="command-input" v-model="formData.responseMechanism" maxlength="200"
                     placeholder="请输入" clearable />
               </el-form-item>
               <el-form-item label="触发条件" prop="triggerCondition">
                  <el-select class="command-select" popper-class="command-select-popper"
@@ -118,10 +149,9 @@
                  </el-select>
               </el-form-item>
               <el-form-item label="可飞行时段" prop="flyDateStart">
                  <el-date-picker class="command-date-picker"
                     popper-class="command-date-picker-popper" v-model="flyDateRange"
                     type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间"
                     :disabled-date="disabledFlyDate" :disabled-time="disabledFlyTime"
                  <el-date-picker class="command-date-picker" popper-class="command-date-picker-popper"
                     v-model="flyDateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间"
                     end-placeholder="结束时间" :disabled-date="disabledFlyDate" :disabled-time="disabledFlyTime"
                     value-format="YYYY-MM-DD HH:mm:ss" clearable />
               </el-form-item>
               <el-form-item label="关联派出所" prop="policeStationId">
@@ -132,20 +162,41 @@
                  </el-select>
               </el-form-item>
               <div class="shape-table-container">
                  <div class="shape-table-title">绘制区域列表</div>
                  <el-table class="command-table" :data="shapeList" row-key="id" height="auto">
                     <el-table-column prop="areaType" label="区域类型">
                        <template v-slot="{ row }">
                           {{ resolveShapeAreaTypeLabel(row) }}
                        </template>
                     </el-table-column>
                     <el-table-column prop="drawType" label="绘制类型">
                        <template v-slot="{ row }">
                           {{ drawTypeLabelMap[row.drawType] || '-' }}
                        </template>
                     </el-table-column>
                     <el-table-column label="操作" class-name="operation-btns" width="120">
                        <template v-slot="{ row }">
                           <el-link @click="handleShapeEdit(row)">编辑</el-link>
                           <el-link @click="handleShapeDelete(row)">删除</el-link>
                        </template>
                     </el-table-column>
                  </el-table>
               </div>
               <div class="search-table-container">
                  <div class="search-box">
                     <div class="label">
                        关联设备
                     </div>
                     <el-input class="command-input" v-model="searchName" placeholder="请输入"
                        clearable></el-input>
                     <el-input class="command-input" v-model="searchName" placeholder="请输入" clearable></el-input>
                  </div>
                  <el-form-item prop="deviceIds" label-width="0">
                     <el-table class="command-table" ref="deviceTableRef"
                        :data="deviceOptions.filter(item => item.deviceName.includes(searchName))" row-key="id"
                        @selection-change="handleDeviceSelectionChange">
                        @selection-change="handleDeviceSelectionChange" @row-click="handleDeviceRowClick">
                        <el-table-column type="selection" width="55" :reserve-selection="true" />
                        <el-table-column prop="deviceName" label="设备名称" />
                        <el-table-column prop="deviceType" label="类型">
@@ -159,7 +210,9 @@
            </el-form>
            <div class="footer">
               <el-button v-if="dialogMode != 'view'" color="#2B2B4C" @click="handleCancel">{{ readonly ? '关闭' : '取消' }}</el-button>
               <el-button v-if="dialogMode != 'view'" color="#2B2B4C" @click="handleCancel">{{ readonly ? '关闭' :
                  '取消'
               }}</el-button>
               <el-button color="#284FE3" v-if="!readonly" type="primary" :loading="submitting"
                  :disabled="submitting" @click="handleSubmit">
                  确定
@@ -173,30 +226,37 @@
<script setup>
import { Close } from '@element-plus/icons-vue'
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { fwAreaDivideDetailApi, fwAreaDivideSubmitApi } from './partitionApi'
import { saveOperationLog } from '@ztzf/apis'
import { useRoute } from 'vue-router'
import { fieldRules, geomAnalysis, getDictLabel } from '@ztzf/utils'
import CommonCesiumMap from '@/components/map-container/common-cesium-map.vue'
import { DrawPolygon } from '@/utils/cesium/DrawPolygon'
import { DrawManager, EditManager, buildEllipsePositions } from '@/utils/cesium/shapeTools'
import { cartesian3Convert } from '@/utils/cesium/mapUtil'
import * as turf from '@turf/turf'
import {
   createDeviceRangePrimitive,
   getDeviceLngLat,
   normalizeDeviceRange,
   resolveDeviceTerrainHeight,
} from '@/utils/cesium/deviceRange'
import * as Cesium from 'cesium'
import { AREA_TYPE_STYLE_MAP, BUFFER_LEVEL_STYLES, DEFAULT_AREA_STYLE } from '@ztzf/constants'
import { fwPoliceStationListApi } from '@/views/areaManage/precinctInfo/precinctInfoApi'
import { fwDeviceListApi } from '@/views/basicManage/deviceStock/fwDevice'
import polygonIcon from '@/assets/images/areaMap/polygon.png'
import rectIcon from '@/assets/images/areaMap/rect.png'
import ellipseIcon from '@/assets/images/areaMap/ellipse.png'
import bufferIcon from '@/assets/images/areaMap/buffer.png'
import trashIcon from '@/assets/images/areaMap/trash.png'
const initForm = () => ({
   areaName: '', // 区域名称
   areaSize: null, // 区域面积
   areaType: '', // 区域类型
   controlLevel: '', // 管控级别
   deviceIds: '', // 关联设备ID
   flyDateEnd: '', // 可飞行结束
   flyDateStart: '', // 可飞行开始
   latitude: null, // 区域中心纬度
   longitude: null, // 区域中心经度
   policeStationId: '', // 关联派出所id
   responseMechanism: '', // 响应机制
   triggerCondition: '', // 触发条件
@@ -219,14 +279,31 @@
const deviceOptions = ref([]) // 关联设备
const dictObj = inject('dictObj')
const mapRef = ref(null)
const currentShapeType = ref('polygon')
const activeAreaType = ref('')
const shapeList = ref([])
const activeShapeId = ref(null)
const drawTypeLabelMap = {
   polygon: '多边形',
   rectangle: '矩形',
   ellipse: '椭圆',
   buffer: '缓冲圆',
}
const bufferAreaTypeValue = '1,2,3'
const showTypePanel = ref(false)
const suppressTypePanelOnce = ref(false)
let viewer
let drawPolygonExample
let drawManager
let editManager
let activeTool
let pointList = []
let viewEntity
let shapeDisplaySource
const deviceRangePrimitiveMap = new Map()
const activeToolMode = ref('')
const rules = {
   areaName: fieldRules(true, 50),
   longitude: fieldRules(true),
   areaType: fieldRules(true),
   responseMechanism: fieldRules(true, 200),
   triggerCondition: fieldRules(true, 200),
   controlLevel: fieldRules(true),
@@ -274,17 +351,33 @@
// 提交新增/编辑
async function handleSubmit () {
   const isValid = await formRef.value?.validate().catch(() => false)
   if (!isValid) return
   if (dialogMode.value === 'add' && Number(formData.value.areaSize) < 0.5) {
      ElMessage.warning('区域面积不能小于0.5km²,请重新绘制')
   if (!shapeList.value.length) {
      ElMessage.warning('区域不可为空,请先绘制区域')
      return
   }
   const isValid = await formRef.value?.validate().catch(() => false)
   if (!isValid) return
   submitting.value = true
   try {
      let str = [...pointList, pointList[0]].map(item => `${item.longitude} ${item.latitude}`).join(',')
      formData.value.geom = `POLYGON((${str}))`
      await fwAreaDivideSubmitApi(formData.value)
      const areaDivide = { ...formData.value }
      delete areaDivide.geom
      const areaDivideExtList = shapeList.value.map(shape => {
         const areaTypeKey = shape?.areaType ?? ''
         const areaTypeValue = getDictLabel(areaTypeKey, resolveAreaTypeOptions())
         return {
            id: shape?.extId,
            areaDivideId: areaDivide?.id ?? '',
            areaCode: areaDivide?.areaCode ?? '',
            areaTypeKey,
            areaTypeValue,
            geomJson: JSON.stringify(shape),
         }
      })
      await fwAreaDivideSubmitApi({
         areaDivide,
         areaDivideExtList,
      })
      const actionText = dialogMode.value === 'add' ? '新增' : '编辑'
      saveOperationLog({
         requestUri: route.path,
@@ -306,19 +399,43 @@
      formData.value.flyDateEnd = flyDateRange.value[1] || ''
   }
)
watch(
   () => visible.value,
   val => {
      if (!val) {
         viewEntity && viewer?.entities?.remove(viewEntity)
         viewEntity = null
         clearActiveTool()
         hideTypePanel()
         clearShapeDisplay()
         clearDeviceRangePrimitives()
      }
   }
)
watch(
   () => activeAreaType.value,
   val => {
      updateActiveShapeAreaType(val)
   }
)
// 加载详情
async function loadDetail () {
   if (!formData.value.id) return
   const res = await fwAreaDivideDetailApi({ id: formData.value.id })
   formData.value = res?.data?.data ?? initForm()
   const detail = res?.data?.data ?? {}
   const nextForm = initForm()
   Object.keys(nextForm).forEach(key => {
      if (detail[key] !== undefined) {
         nextForm[key] = detail[key]
      }
   })
   if (detail?.id) {
      nextForm.id = detail.id
   }
   formData.value = nextForm
   flyDateRange.value = [formData.value.flyDateStart, formData.value.flyDateEnd].filter(Boolean)
}
// 格式化区域位置
function formatLocation (row) {
   if (row?.longitude == null || row?.latitude == null) return ''
   return `${_.round(row.longitude, 6)}, ${_.round(row.latitude, 6)}`
   syncShapeListFromDetail(detail)
}
// 格式化可飞行时间段
@@ -334,66 +451,531 @@
   viewer = map?.viewer || null
}
function ensureShapeDisplaySource () {
   if (!viewer) return null
   if (!shapeDisplaySource) {
      shapeDisplaySource = new Cesium.CustomDataSource('partition-shape-display')
      viewer.dataSources.add(shapeDisplaySource)
   }
   return shapeDisplaySource
}
function clearDeviceRangePrimitives () {
   if (!viewer || !deviceRangePrimitiveMap.size) return
   for (const primitive of deviceRangePrimitiveMap.values()) {
      viewer.scene.primitives.remove(primitive)
   }
   deviceRangePrimitiveMap.clear()
}
async function renderDeviceRangeSingle (device) {
   if (!viewer) return
   clearDeviceRangePrimitives()
   const position = getDeviceLngLat(device)
   if (!position) return
   const rangeMeters = normalizeDeviceRange(device.effectiveRangeKm)
   const primitive = await createDeviceRangePrimitive(viewer, position, rangeMeters)
   if (!primitive) return
   deviceRangePrimitiveMap.set(String(device?.id ?? Date.now()), primitive)
   viewer.scene.primitives.add(primitive)
}
async function renderDeviceRanges (rows) {
   if (!viewer) return
   const selectedRows = Array.isArray(rows) ? rows : []
   const nextIds = new Set(selectedRows.map(row => String(row?.id)))
   for (const [id, primitive] of deviceRangePrimitiveMap.entries()) {
      if (!nextIds.has(id)) {
         viewer.scene.primitives.remove(primitive)
         deviceRangePrimitiveMap.delete(id)
      }
   }
   const pendingRows = selectedRows.filter(row => {
      const id = String(row?.id)
      return id && !deviceRangePrimitiveMap.has(id)
   })
   if (!pendingRows.length) return
   const primitives = await Promise.all(
      pendingRows.map(async row => {
         const position = getDeviceLngLat(row)
         if (!position) return { row, primitive: null }
         const rangeMeters = normalizeDeviceRange(row.effectiveRangeKm)
         const primitive = await createDeviceRangePrimitive(viewer, position, rangeMeters)
         return { row, primitive }
      })
   )
   primitives.forEach(({ row, primitive }) => {
      if (!primitive) return
      const id = String(row?.id)
      deviceRangePrimitiveMap.set(id, primitive)
      viewer.scene.primitives.add(primitive)
   })
}
async function flyToDevice (device) {
   if (!viewer) return
   const position = getDeviceLngLat(device)
   if (!position) return
   const height = await resolveDeviceTerrainHeight(viewer, position)
   const rangeMeters = normalizeDeviceRange(device?.effectiveRangeKm)
   const destination = Cesium.Cartesian3.fromDegrees(position.lng, position.lat, height + rangeMeters * 3)
   viewer.camera.flyTo({
      destination,
      orientation: {
         heading: viewer.camera.heading,
         pitch: Cesium.Math.toRadians(-90),
         roll: 0,
      },
      duration: 0.6,
   })
}
function clearShapeDisplay () {
   if (!shapeDisplaySource) return
   shapeDisplaySource.entities.removeAll()
}
function normalizeShapePoint (point) {
   const lng = point?.lng ?? point?.longitude
   const lat = point?.lat ?? point?.latitude
   if (!Number.isFinite(Number(lng)) || !Number.isFinite(Number(lat))) return null
   return {
      lng: Number(lng),
      lat: Number(lat),
      height: Number.isFinite(Number(point?.height)) ? Number(point.height) : 0,
   }
}
function resolveLngLatPoint (point) {
   if (!point) return null
   const lng = point?.lng ?? point?.longitude
   const lat = point?.lat ?? point?.latitude
   if (Number.isFinite(Number(lng)) && Number.isFinite(Number(lat))) {
      return {
         lng: Number(lng),
         lat: Number(lat),
         height: Number.isFinite(Number(point?.height)) ? Number(point.height) : 0,
      }
   }
   if (!viewer || !Number.isFinite(point?.x) || !Number.isFinite(point?.y) || !Number.isFinite(point?.z)) {
      return null
   }
   const val = cartesian3Convert(point, viewer)
   return {
      lng: val.longitude,
      lat: val.latitude,
      height: Number.isFinite(val.height) ? val.height : 0,
   }
}
function resolveControlPoints (meta, drawType) {
   if (!meta) return []
   if (Array.isArray(meta?.controlPoints)) {
      return meta.controlPoints.map(resolveLngLatPoint).filter(Boolean)
   }
   if (drawType === 'ellipse') {
      return [meta.center, meta.majorPoint, meta.minorPoint].map(resolveLngLatPoint).filter(Boolean)
   }
   if (drawType === 'buffer') {
      return [meta.center, meta.radiusPoint1, meta.radiusPoint2, meta.radiusPoint3]
         .map(resolveLngLatPoint)
         .filter(Boolean)
   }
   return []
}
function buildShapePositions (points = []) {
   const normalized = points.map(normalizeShapePoint).filter(Boolean)
   return normalized.map(point =>
      Cesium.Cartesian3.fromDegrees(point.lng, point.lat, point.height)
   )
}
function getShapeDisplayPoints (shape) {
   if (Array.isArray(shape?.displayPoints) && shape.displayPoints.length) {
      return shape.displayPoints
   }
   return shape?.points || []
}
function getAreaTypeStyle (areaType) {
   return AREA_TYPE_STYLE_MAP?.[String(areaType)] || DEFAULT_AREA_STYLE
}
function renderShapeList () {
   if (!viewer || !visible.value) {
      clearShapeDisplay()
      return
   }
   const dataSource = ensureShapeDisplaySource()
   if (!dataSource) return
   dataSource.entities.removeAll()
   shapeList.value.forEach(shape => {
      if (activeToolMode.value === 'edit' && shape.id === activeShapeId.value) return
      if (shape.drawType === 'buffer' && shape.meta?.bufferRadii?.length && shape.meta?.center) {
         const center = shape.meta.center
         const centerCartesian = Cesium.Cartesian3.fromDegrees(center.lng, center.lat, center.height || 0)
         const [radius1, radius2, radius3] = shape.meta.bufferRadii
         const radii = [radius1, radius2, radius3].filter(item => Number.isFinite(item) && item > 0)
         if (radii.length) {
            radii.forEach((radius, index) => {
               const style = BUFFER_LEVEL_STYLES[index] || BUFFER_LEVEL_STYLES[BUFFER_LEVEL_STYLES.length - 1]
               dataSource.entities.add({
                  name: 'shape-display',
                  customData: { shapeId: shape.id, drawType: shape.drawType, level: index + 1 },
                  position: centerCartesian,
                  ellipse: {
                     semiMajorAxis: radius,
                     semiMinorAxis: radius,
                     material: style.fill,
                     outline: false,
                     heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
                  },
               })
               const positions = buildEllipsePositions(centerCartesian, radius, radius)
               dataSource.entities.add({
                  name: 'shape-display',
                  customData: { shapeId: shape.id, drawType: shape.drawType, level: index + 1, outline: true },
                  polyline: {
                     positions: positions.length ? [...positions, positions[0]] : positions,
                     clampToGround: true,
                     width: 2,
                     material: style.outline,
                  },
               })
            })
         }
         return
      }
      if (!shape?.points?.length) return
      const positions = buildShapePositions(getShapeDisplayPoints(shape))
      if (positions.length < 3) return
      const style = getAreaTypeStyle(shape.areaType)
      dataSource.entities.add({
         name: 'shape-display',
         customData: { shapeId: shape.id, drawType: shape.drawType },
         polygon: {
            hierarchy: new Cesium.PolygonHierarchy(positions),
            material: style.fill,
            outline: false,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
         polyline: {
            positions: positions.length ? [...positions, positions[0]] : positions,
            clampToGround: true,
            width: 2,
            material: style.outline,
         },
      })
   })
}
// 绘制完成回调
const drawFinished = async data => {
   pointList = _.cloneDeep(data).map(item => {
   const positions = Array.isArray(data?.positions) ? data.positions : data
   if (!Array.isArray(positions) || positions.length < 3) {
      pointList = []
      hideTypePanel()
      return
   }
   const meta = data?.meta
   const bufferRadii = Array.isArray(meta?.bufferRadii) ? meta.bufferRadii : null
   const centerCartesian = meta?.center
   const centerLngLat = centerCartesian ? cartesian3Convert(centerCartesian, viewer) : null
   pointList = _.cloneDeep(positions).map(item => {
      const val = cartesian3Convert(item, viewer)
      return { ...val, lng: val.longitude, lat: val.latitude }
   })
   const polygon = turf.polygon([
      [...pointList.map(item => [item.longitude, item.latitude]), [pointList[0].longitude, pointList[0].latitude]],
   ])
   const center = turf.centerOfMass(polygon)
   const areaSqm = turf.area(polygon) // 平方米
   formData.value.areaSize = _.round(areaSqm / 1_000_000, 2) // 平方千米
   formData.value.longitude = center.geometry.coordinates[0]
   formData.value.latitude = center.geometry.coordinates[1]
}
// 新增模式绘制
function addPolygon () {
   drawPolygonExample = new DrawPolygon(viewer)
   drawPolygonExample.initHandler(viewer)
   drawPolygonExample.subscribe('getPolygonPositions', drawFinished)
}
// 编辑面
function editPolygon () {
   if (!formData.value?.geom) return
   pointList = geomAnalysis(formData.value.geom)
   drawPolygonExample?.destroy()
   drawPolygonExample = new DrawPolygon(viewer)
   let pointList1 = _.cloneDeep(pointList)
   pointList1.pop()
   drawPolygonExample.initPolygon(
      viewer,
      pointList.map(item => ({ lng: item.longitude, lat: item.latitude }))
   )
   drawPolygonExample.subscribe('getPolygonPositions', drawFinished)
   if (pointList1.length) {
      const result = pointList1.map(item => [item.longitude, item.latitude]).flat()
      const tempEntity = viewer.entities?.add({
         position: Cesium.Cartesian3.fromDegrees(result[0], result[1]),
         polyline: {
            positions: Cesium.Cartesian3.fromDegreesArray(result),
            clampToGround: true,
            width: 1,
            material: Cesium.Color.TRANSPARENT,
         },
      })
      if (tempEntity) {
         viewer.flyTo(tempEntity, { duration: 0 })
         viewer.entities.remove(tempEntity)
   const controlPoints = resolveControlPoints(meta, currentShapeType.value)
   if (!activeAreaType.value) {
      const detectAreaType = getDetectAreaTypeKey()
      if (detectAreaType) {
         activeAreaType.value = detectAreaType
      }
   }
   if (currentShapeType.value === 'buffer') {
      activeAreaType.value = bufferAreaTypeValue.split(',')
   }
   if (!suppressTypePanelOnce.value) {
      showTypePanelAtCurrent()
   }
   suppressTypePanelOnce.value = false
   if (!activeShapeId.value) {
      activeShapeId.value = `shape_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`
   }
   const targetIndex = shapeList.value.findIndex(item => item.id === activeShapeId.value)
   const displayPoints = ['ellipse', 'buffer'].includes(currentShapeType.value)
      ? _.cloneDeep(pointList)
      : null
   let shapeMeta = null
   if (currentShapeType.value === 'buffer' && bufferRadii?.length && centerLngLat) {
      shapeMeta = {
         bufferRadii,
         center: { lng: centerLngLat.longitude, lat: centerLngLat.latitude, height: centerLngLat.height ?? 0 },
      }
      if (controlPoints.length) {
         shapeMeta.controlPoints = controlPoints
      }
   } else if (currentShapeType.value === 'ellipse') {
      const centerPoint = resolveLngLatPoint(meta?.center)
      const majorPoint = resolveLngLatPoint(meta?.majorPoint)
      const minorPoint = resolveLngLatPoint(meta?.minorPoint)
      if (centerPoint || majorPoint || minorPoint) {
         shapeMeta = {
            center: centerPoint,
            majorPoint,
            minorPoint,
            semiMajor: Number.isFinite(Number(meta?.semiMajor)) ? Number(meta.semiMajor) : null,
            semiMinor: Number.isFinite(Number(meta?.semiMinor)) ? Number(meta.semiMinor) : null,
         }
         if (controlPoints.length) {
            shapeMeta.controlPoints = controlPoints
         }
      }
   }
   const shapePayload = {
      id: activeShapeId.value,
      areaType: currentShapeType.value === 'buffer' ? bufferAreaTypeValue : activeAreaType.value,
      drawType: currentShapeType.value,
      points: ['ellipse', 'buffer'].includes(currentShapeType.value) && controlPoints.length
         ? controlPoints
         : _.cloneDeep(pointList),
      displayPoints,
      meta: shapeMeta,
   }
   if (targetIndex === -1) {
      shapeList.value.push(shapePayload)
   } else {
      shapeList.value.splice(targetIndex, 1, shapePayload)
   }
   renderShapeList()
}
function getDetectAreaTypeKey () {
   const areaTypeOptions = resolveAreaTypeOptions()
   if (!Array.isArray(areaTypeOptions)) return ''
   const target = areaTypeOptions.find(item => item?.dictKey === '1')
   return target?.dictKey ?? ''
}
function resolveAreaTypeOptions () {
   return Array.isArray(dictObj?.value?.areaType)
      ? dictObj.value.areaType
      : dictObj?.areaType
}
function resolveShapeAreaTypeLabel (shape) {
   return getDictLabel(shape?.areaType, resolveAreaTypeOptions())
}
function parseGeomJson (geomJson) {
   if (!geomJson) return null
   if (typeof geomJson === 'object') return geomJson
   if (typeof geomJson !== 'string') return null
   const trimmed = geomJson.trim()
   if (!trimmed) return null
   try {
      return JSON.parse(trimmed)
   } catch (error) {
      const legacyPoints = geomAnalysis(trimmed)
      if (Array.isArray(legacyPoints) && legacyPoints.length >= 3) {
         return { drawType: 'polygon', points: legacyPoints }
      }
   }
   return null
}
function syncShapeListFromDetail (detail) {
   const extList = Array.isArray(detail?.fwAreaDivideExtList) ? detail.fwAreaDivideExtList : []
   if (!extList.length) {
      shapeList.value = []
      activeAreaType.value = ''
      currentShapeType.value = 'polygon'
      return
   }
   shapeList.value = extList.map((item, index) => {
      const isShapePayload = item?.drawType || item?.points
      if (isShapePayload) {
         const areaType = item?.drawType === 'buffer'
            ? bufferAreaTypeValue
            : (item?.areaType ?? '')
         return {
            id: item?.id || `shape_${Date.now()}_${index}_${Math.random().toString(16).slice(2, 6)}`,
            extId: item?.extId ?? item?.id,
            areaType,
            drawType: item?.drawType ?? 'polygon',
            points: Array.isArray(item?.points) ? item.points : [],
            displayPoints: Array.isArray(item?.displayPoints) ? item.displayPoints : null,
            meta: item?.meta ?? null,
         }
      }
      const parsed = parseGeomJson(item?.geomJson) || {}
      const drawType = parsed?.drawType || 'polygon'
      const areaType = drawType === 'buffer'
         ? bufferAreaTypeValue
         : (item?.areaTypeKey ?? parsed?.areaType ?? '')
      const points = Array.isArray(parsed?.points) ? parsed.points : []
      const displayPoints = Array.isArray(parsed?.displayPoints) ? parsed.displayPoints : null
      const meta = parsed?.meta ?? null
      return {
         id: parsed?.id || `shape_${Date.now()}_${index}_${Math.random().toString(16).slice(2, 6)}`,
         extId: item?.id,
         areaType,
         drawType,
         points,
         displayPoints,
         meta,
      }
   })
   activeAreaType.value = shapeList.value[0]?.drawType === 'buffer'
      ? bufferAreaTypeValue.split(',')
      : (shapeList.value[0]?.areaType ?? '')
   currentShapeType.value = shapeList.value[0]?.drawType ?? 'polygon'
}
function showTypePanelAtCurrent () {
   if (readonly.value) return
   showTypePanel.value = true
}
function hideTypePanel () {
   showTypePanel.value = false
}
function handleTypePanelClose () {
   hideTypePanel()
   if (readonly.value) return
   if (activeToolMode.value !== 'edit') return
   const positions = activeTool?.getPositions?.()
   if (Array.isArray(positions) && positions.length >= 3) {
      suppressTypePanelOnce.value = true
      drawFinished(positions)
   }
   if (positions?.positions?.length >= 3) {
      suppressTypePanelOnce.value = true
      drawFinished(positions)
   }
   clearActiveTool()
}
function handleAreaTypeChange (value) {
   updateActiveShapeAreaType(value)
}
function updateActiveShapeAreaType (value) {
   if (currentShapeType.value === 'buffer') return
   activeTool?.setStyle?.(getAreaTypeStyle(value))
   if (!activeShapeId.value) return
   const targetIndex = shapeList.value.findIndex(item => item.id === activeShapeId.value)
   if (targetIndex === -1) return
   const target = shapeList.value[targetIndex]
   shapeList.value.splice(targetIndex, 1, {
      ...target,
      areaType: value,
   })
   renderShapeList()
}
// Tool lifecycle
function clearActiveTool () {
   activeTool?.destroy?.()
   activeTool = null
   drawManager?.destroy()
   editManager?.destroy()
   activeToolMode.value = ''
   renderShapeList()
}
function startDraw (type) {
   currentShapeType.value = type
   activeToolMode.value = 'draw'
   drawManager ||= new DrawManager(viewer)
   activeTool = drawManager.start(type, { style: getAreaTypeStyle(activeAreaType.value) })
   activeTool?.subscribe('getPolygonPositions', drawFinished)
}
function startEdit (type, points) {
   currentShapeType.value = type
   activeToolMode.value = 'edit'
   editManager ||= new EditManager(viewer)
   activeTool = editManager.start(type, points, { style: getAreaTypeStyle(activeAreaType.value) })
   activeTool?.subscribe('getPolygonPositions', drawFinished)
}
function handleDraw (type) {
   if (readonly.value) return
   if (!viewer) return
   clearActiveTool()
   viewEntity && viewer?.entities?.remove(viewEntity)
   viewEntity = null
   hideTypePanel()
   renderShapeList()
   pointList = []
   const defaultAreaType = getDetectAreaTypeKey()
   if (type === 'buffer') {
      activeAreaType.value = bufferAreaTypeValue
   } else if (defaultAreaType) {
      activeAreaType.value = defaultAreaType
   }
   activeShapeId.value = null
   startDraw(type)
}
function handleClearShape () {
   if (readonly.value) return
   clearActiveTool()
   viewEntity && viewer?.entities?.remove(viewEntity)
   viewEntity = null
   pointList = []
   shapeList.value = []
   activeShapeId.value = null
   activeAreaType.value = ''
   hideTypePanel()
   clearShapeDisplay()
}
function handleShapeEdit (row) {
   if (!row?.points?.length) return
   if (activeToolMode.value === 'edit' && activeShapeId.value === row.id) return
   activeShapeId.value = row.id
   currentShapeType.value = row.drawType
   activeAreaType.value = row.drawType === 'buffer' ? bufferAreaTypeValue.split(',') : row.areaType
   const displayPoints = getShapeDisplayPoints(row)
   pointList = _.cloneDeep(displayPoints)
   clearActiveTool()
   const editPayload = ['buffer', 'ellipse'].includes(row.drawType)
      ? { points: displayPoints, meta: row.meta }
      : row.points
   startEdit(row.drawType, editPayload)
   showTypePanelAtCurrent()
   renderShapeList()
}
function handleShapeDelete (row) {
   if (!row) return
   shapeList.value = shapeList.value.filter(item => item.id !== row.id)
   renderShapeList()
   if (activeShapeId.value === row.id) {
      activeShapeId.value = null
      clearActiveTool()
      pointList = []
      hideTypePanel()
   }
}
// 查看面
function viewPolygon () {
   if (!viewer) return
   if (shapeList.value.length) {
      renderShapeList()
      return
   }
   if (!formData.value?.geom) return
   pointList = geomAnalysis(formData.value.geom)
   const result = pointList.map(item => [item.longitude, item.latitude]).flat()
   const mian = viewer.entities?.add({
   viewEntity = viewer.entities?.add({
      customType: 'control_group',
      position: Cesium.Cartesian3.fromDegrees(result[0], result[1]),
      polyline: {
@@ -403,7 +985,7 @@
         material: Cesium.Color.RED,
      },
   })
   viewer.flyTo(mian, { duration: 0 })
   viewer.flyTo(viewEntity, { duration: 0 })
}
// 获取派出所列表
@@ -427,21 +1009,42 @@
   selectedDeviceRows.value = rows
   const ids = rows.map(item => item.id)
   formData.value.deviceIds = ids.join(',')
   renderDeviceRanges(rows)
   const target = rows[rows.length - 1]
   if (target) {
      void flyToDevice(target)
   }
}
function handleDeviceRowClick (row) {
   if (readonly.value) {
      renderDeviceRangeSingle(row)
      void flyToDevice(row)
      return
   }
   renderDeviceRanges(selectedDeviceRows.value)
   void flyToDevice(row)
}
// 同步选择状态
function syncDeviceSelection () {
   if (!deviceTableRef.value) return
   deviceTableRef.value.clearSelection()
   const rows = []
   const arr = formData.value.deviceIds.split(',')
   deviceOptions.value.forEach(row => {
      if (arr.includes(row.id)) {
         deviceTableRef.value.toggleRowSelection(row, true)
         rows.push(row)
      }
   })
   selectedDeviceRows.value = rows
   if (readonly.value) {
      renderDeviceRanges(selectedDeviceRows.value)
      return
   }
   if (!deviceTableRef.value) return
   deviceTableRef.value.clearSelection()
   rows.forEach(row => {
      deviceTableRef.value.toggleRowSelection(row, true)
   })
}
// 打开弹框
@@ -449,25 +1052,40 @@
   dialogMode.value = mode || 'add'
   formData.value = dialogMode.value === 'add' ? initForm() : row
   selectedDeviceRows.value = []
   shapeList.value = []
   activeShapeId.value = null
   activeAreaType.value = ''
   pointList = []
   await nextTick()
   initMap()
   clearShapeDisplay()
   clearDeviceRangePrimitives()
   viewEntity && viewer?.entities?.remove(viewEntity)
   viewEntity = null
   clearActiveTool()
   await getDeviceList()
   if (dialogMode.value === 'add') {
      addPolygon()
      // default no draw mode
   } else if (dialogMode.value === 'edit') {
      await loadDetail()
      editPolygon()
      viewPolygon()
   } else {
      await loadDetail()
      viewPolygon()
   }
   await nextTick()
   syncDeviceSelection()
   if (!readonly.value) {
      renderDeviceRanges(selectedDeviceRows.value)
   }
}
onMounted(() => {
   getPoliceStationList()
   getDeviceList()
})
onBeforeUnmount(() => {
   hideTypePanel()
})
defineExpose({
@@ -475,4 +1093,168 @@
})
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.left-container {
   position: relative;
}
.shape-type-panel {
   position: absolute;
   right: 16px;
   bottom: 16px;
   z-index: 6;
   min-width: 260px;
   background: rgba(22, 33, 44, 0.9);
   border: 1px solid rgba(255, 255, 255, 0.15);
   border-radius: 8px;
   box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
   backdrop-filter: blur(6px);
   color: #e5e7f0;
}
.panel-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
   padding: 8px 12px;
   border-bottom: 1px solid rgba(255, 255, 255, 0.12);
   font-size: 13px;
   font-weight: 600;
}
.panel-close {
   cursor: pointer;
   color: rgba(255, 255, 255, 0.7);
}
.panel-body {
   display: flex;
   align-items: center;
   gap: 8px;
   padding: 10px 12px 12px;
}
.panel-label {
   font-size: 12px;
   color: rgba(255, 255, 255, 0.75);
   white-space: nowrap;
}
.map-toolbar {
   position: absolute;
   top: 64px;
   right: 16px;
   display: flex;
   flex-direction: column;
   gap: 10px;
   padding: 10px 8px;
   background: rgba(19, 28, 36, 0.75);
   backdrop-filter: blur(4px);
   border-radius: 8px;
   box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2);
}
.tool-btn {
   padding: 0;
   display: flex;
   flex-direction: column;
   align-items: center;
   gap: 6px;
   background: rgba(255, 255, 255, 0.08);
   border: 1px solid rgba(255, 255, 255, 0.18);
   border-radius: 8px;
   color: #ffffff;
   font-size: 12px;
   line-height: 1;
   cursor: pointer;
   transition: all 0.2s ease;
   overflow: hidden;
}
.tool-btn:hover {
   background: rgba(255, 255, 255, 0.16);
   border-color: rgba(255, 255, 255, 0.35);
   transform: translateY(-1px);
}
.tool-btn:active {
   transform: translateY(0);
}
.tool-btn.danger {
   border-color: rgba(255, 112, 112, 0.6);
   color: #ffb3b3;
}
.tool-icon {
   position: relative;
}
.tool-icon-image {
   width: 36px;
   height: 36px;
}
.tool-icon.polygon {
   border-radius: 4px;
   transform: rotate(15deg);
}
.tool-icon.rect {
   border-radius: 2px;
}
.tool-icon.ellipse {
   border-radius: 50%;
}
.tool-icon.buffer {
   border-radius: 50%;
   box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.tool-icon.trash {
   border: none;
   box-shadow: none;
}
.tool-icon.trash::before,
.tool-icon.trash::after {
   content: "";
   position: absolute;
   left: 4px;
   right: 4px;
   background: #ffffff;
}
.tool-icon.trash::before {
   top: 4px;
   height: 10px;
   border-radius: 2px;
}
.tool-icon.trash::after {
   top: 1px;
   height: 3px;
   border-radius: 2px;
}
.shape-table-container {
   margin-bottom: 20px;
   .shape-table-title {
      margin-bottom: 20px;
      display: flex;
      align-items: center;
      justify-content: flex-start;
      padding-right: 1.2rem;
      font-family: Source Han Sans CN, Source Han Sans CN;
      font-weight: 500;
      font-size: 1.4rem;
      color: #D4D5D7;
      font-style: normal;
      text-transform: none;
      box-sizing: border-box;
   }
}
</style>