吉安感知网项目-前端
罗广辉
2026-08-13 1d552a3bad95caae4af15322df28e6240b797693
applications/drone-command/src/views/areaManage/partition/FormDiaLog.vue
@@ -3,7 +3,7 @@
      <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" />
               :terrain="true" :layer-mode="4" :boundary="false" :zoom-to-boundary="dialogMode === 'add'" />
            <div v-if="showTypePanel && !readonly" class="shape-type-panel">
               <div class="panel-header">
                  <span>{{ drawTypeLabelMap[currentShapeType] || '区域' }}</span>
@@ -14,8 +14,8 @@
               <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">
                     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>
@@ -23,22 +23,26 @@
            </div>
            <div class="map-toolbar">
               <el-tooltip content="多边形" placement="left">
                  <button class="tool-btn" type="button" @click="handleDraw('polygon')">
                  <button class="tool-btn" :class="{ active: currentShapeType === 'polygon' }" 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')">
                  <button class="tool-btn" :class="{ active: currentShapeType === 'rectangle' }" 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')">
                  <button class="tool-btn" :class="{ active: currentShapeType === 'ellipse' }" 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')">
                  <button class="tool-btn" :class="{ active: currentShapeType === 'buffer' }" type="button"
                     @click="handleDraw('buffer')">
                     <img class="tool-icon-image" :src="bufferIcon" alt="缓冲圆" />
                  </button>
               </el-tooltip>
@@ -93,7 +97,7 @@
               <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 class="command-table" :data="shapeList" row-key="id"  height="240">
                        <el-table-column prop="areaType" label="区域类型">
                           <template v-slot="{ row }">
                              {{ resolveShapeAreaTypeLabel(row) }}
@@ -112,7 +116,7 @@
               <div class="command-table-container">
                  <div class="command-table-content">
                     <el-table class="command-table" ref="deviceTableRef" :data="deviceOptions" row-key="id"
                        @row-click="handleDeviceRowClick">
                        @row-click="handleDeviceRowClick"  height="240">
                        <el-table-column prop="deviceName" label="设备名称" />
                        <el-table-column prop="deviceType" label="类型">
                           <template v-slot="{ row }">
@@ -125,7 +129,7 @@
            </div>
            <el-form class="dialog-form" v-else ref="formRef" :model="formData" :rules="rules" :disabled="readonly"
               label-width="96px">
               label-width="116px">
               <el-form-item label="区域名称" prop="areaName">
                  <el-input class="command-input" v-model="formData.areaName" maxlength="50" placeholder="请输入"
                     clearable />
@@ -156,7 +160,8 @@
               </el-form-item>
               <el-form-item label="关联派出所" prop="policeStationId">
                  <el-select class="command-select" popper-class="command-select-popper"
                     v-model="formData.policeStationId" placeholder="请选择" clearable>
                     v-model="formData.policeStationId" placeholder="请选择" clearable filterable remote
                     :remote-method="handlePoliceStationSearch" :loading="policeStationLoading">
                     <el-option v-for="item in policeStationOptions" :key="item.id" :label="item.stationName"
                        :value="item.id" />
                  </el-select>
@@ -164,7 +169,7 @@
               <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 class="command-table" :data="shapeList" row-key="id" height="240">
                     <el-table-column prop="areaType" label="区域类型">
                        <template v-slot="{ row }">
                           {{ resolveShapeAreaTypeLabel(row) }}
@@ -175,7 +180,7 @@
                           {{ drawTypeLabelMap[row.drawType] || '-' }}
                        </template>
                     </el-table-column>
                     <el-table-column label="操作" class-name="operation-btns" width="120">
                     <el-table-column label="操作" class-name="operation-btns" fixed="right" width="120">
                        <template v-slot="{ row }">
                           <el-link @click="handleShapeEdit(row)">编辑</el-link>
                           <el-link @click="handleShapeDelete(row)">删除</el-link>
@@ -196,7 +201,7 @@
                  <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" @row-click="handleDeviceRowClick">
                        @selection-change="handleDeviceSelectionChange" @row-click="handleDeviceRowClick"  height="240">
                        <el-table-column type="selection" width="55" :reserve-selection="true" />
                        <el-table-column prop="deviceName" label="设备名称" />
                        <el-table-column prop="deviceType" label="类型">
@@ -226,24 +231,36 @@
<script setup>
import { Close } from '@element-plus/icons-vue'
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, inject, nextTick, onBeforeUnmount, 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 { DrawManager, EditManager, buildEllipsePositions } from '@/utils/cesium/shapeTools'
import { cartesian3Convert, getPointPositionsHeight } from '@/utils/cesium/mapUtil'
import {
   DrawManager,
   EditManager,
   addBufferRadiusLabelEntity,
   buildEllipsePositions,
} from '@/utils/cesium/shapeTools'
import { cartesian3Convert } from '@/utils/cesium/mapUtil'
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 { fwDeviceListByPolygonsApi } 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'
import equipmentIcon from '@/assets/images/dataCockpit/map/equipment.png'
const initForm = () => ({
   areaName: '', // 区域名称
@@ -260,7 +277,7 @@
const formRef = ref(null) // 表单实例
const formData = ref(initForm()) // 表单数据
const visible = defineModel() // 弹框显隐
const dialogMode = ref('add') // 弹框模式
const dialogMode = ref() // 弹框模式
const submitting = ref(false) // 提交中
const readonly = computed(() => dialogMode.value === 'view')
const route = useRoute()
@@ -270,6 +287,10 @@
const searchName = ref('')
const selectedDeviceRows = ref([])
const policeStationOptions = ref([]) // 关联派出所
const policeStationAllOptions = ref([])
const policeStationLoading = ref(false)
const policeStationSearchTimer = ref(null)
const policeStationSearchKeyword = ref('')
const deviceOptions = ref([]) // 关联设备
const dictObj = inject('dictObj')
const mapRef = ref(null)
@@ -294,8 +315,15 @@
let viewEntity
let shapeDisplaySource
const deviceRangePrimitiveMap = new Map()
let deviceRangeMaterialRegistered = false
const deviceIconEntityMap = new Map()
const activeToolMode = ref('')
const suppressDeviceFly = ref(false)
const isDialogInitializing = ref(false)
let lastSelectedDeviceIds = []
let deviceRangeRenderToken = 0
let deviceListTimer = null
let deviceListRequestToken = 0
let deviceMapRequestToken = 0
const rules = {
   areaName: fieldRules(true, 50),
@@ -398,12 +426,21 @@
   () => visible.value,
   val => {
      if (!val) {
         if (deviceListTimer) {
            clearTimeout(deviceListTimer)
            deviceListTimer = null
         }
         if (policeStationSearchTimer.value) {
            clearTimeout(policeStationSearchTimer.value)
            policeStationSearchTimer.value = null
         }
         viewEntity && viewer?.entities?.remove(viewEntity)
         viewEntity = null
         clearActiveTool()
         hideTypePanel()
         clearShapeDisplay()
         clearDeviceRangePrimitives()
         clearDeviceIconEntities()
      }
   }
)
@@ -412,6 +449,14 @@
   val => {
      updateActiveShapeAreaType(val)
   }
)
watch(
   () => shapeList.value,
   () => {
      if (isDialogInitializing.value) return
      queueDeviceListRefresh()
   },
   { deep: true }
)
// 加载详情
@@ -455,42 +500,6 @@
   return shapeDisplaySource
}
function registerDeviceRangeMaterial () {
   if (deviceRangeMaterialRegistered || !Cesium?.Material) return
   deviceRangeMaterialRegistered = true
   Cesium.Material._materialCache.addMaterial('DeviceRangeEllipsoid', {
      fabric: {
         type: 'DeviceRangeEllipsoid',
         uniforms: {
            color: new Cesium.Color(1.0, 0.0, 0.0, 0.7),
            speed: 3.0,
         },
         source: `
            uniform vec4 color;
            uniform float speed;
            czm_material czm_getMaterial(czm_materialInput materialInput){
               czm_material material = czm_getDefaultMaterial(materialInput);
               vec2 st = materialInput.st;
               float time = fract(czm_frameNumber * speed / 1000.0);
               float alpha = abs(smoothstep(0.5,1.,fract( -st.t - time)));
               alpha += .1;
               material.alpha = alpha;
               material.diffuse = color.rgb;
               return material;
            }
         `,
      },
      translucent: () => true,
   })
}
function createDeviceRangeMaterial () {
   registerDeviceRangeMaterial()
   return Cesium.Material.fromType('DeviceRangeEllipsoid', {
      color: Cesium.Color.fromBytes(0, 255, 255, 155),
   })
}
function clearDeviceRangePrimitives () {
   if (!viewer || !deviceRangePrimitiveMap.size) return
   for (const primitive of deviceRangePrimitiveMap.values()) {
@@ -499,80 +508,176 @@
   deviceRangePrimitiveMap.clear()
}
function normalizeDeviceRange (value) {
   const range = Number(value)
   if (!Number.isFinite(range) || range <= 0) return 2000
   return range
function clearDeviceIconEntities () {
   if (!viewer || !deviceIconEntityMap.size) return
   for (const entity of deviceIconEntityMap.values()) {
      viewer.entities.remove(entity)
   }
   deviceIconEntityMap.clear()
}
function getDeviceLngLat (device) {
   const lngRaw = device?.longitude ?? device?.lng ?? device?.lon
   const latRaw = device?.latitude ?? device?.lat
   const lng = Number(lngRaw)
   const lat = Number(latRaw)
   if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
   return { lng, lat }
}
async function resolveDeviceTerrainHeight (position) {
   if (!viewer) return 0
   try {
      const results = await getPointPositionsHeight([{ lng: position.lng, lat: position.lat }], viewer)
      const height = Number(results?.[0]?.ASL)
      return Number.isFinite(height) ? height : 0
   } catch (error) {
      return 0
function clearDeviceListState () {
   deviceOptions.value = []
   selectedDeviceRows.value = []
   formData.value.deviceIds = ''
   if (deviceTableRef.value) {
      deviceTableRef.value.clearSelection()
   }
}
async function createDeviceRangePrimitive (device) {
   if (!viewer) return null
   if (!device) return null
   const position = getDeviceLngLat(device)
   if (!position) return null
   const rangeMeters = normalizeDeviceRange(device.effectiveRangeKm)
   const centerHeight = await resolveDeviceTerrainHeight(position)
   const center = Cesium.Cartesian3.fromDegrees(position.lng, position.lat, centerHeight)
   const radii = new Cesium.Cartesian3(rangeMeters, rangeMeters, rangeMeters)
   const geometry = new Cesium.EllipsoidGeometry({
      radii,
      vertexFormat: Cesium.MaterialAppearance.MaterialSupport.TEXTURED?.vertexFormat
         || Cesium.VertexFormat.POSITION_AND_NORMAL,
   })
   const instance = new Cesium.GeometryInstance({
      geometry,
      modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(center),
   })
   return new Cesium.Primitive({
      geometryInstances: instance,
      appearance: new Cesium.MaterialAppearance({
         material: createDeviceRangeMaterial(),
         closed: true,
         translucent: true,
         flat: true,
         faceForward: true,
      }),
      asynchronous: false
   })
function formatWktNumber (value) {
   if (!Number.isFinite(Number(value))) return null
   return Number(value).toFixed(6)
}
async function renderDeviceRangeSingle (device) {
   if (!viewer) return
   clearDeviceRangePrimitives()
   const primitive = await createDeviceRangePrimitive(device)
   if (!primitive) return
   deviceRangePrimitiveMap.set(String(device?.id ?? Date.now()), primitive)
   viewer.scene.primitives.add(primitive)
function isSameWktPoint (left, right) {
   if (!left || !right) return false
   return Math.abs(left.lat - right.lat) < 0.000001 && Math.abs(left.lng - right.lng) < 0.000001
}
function normalizePolygonPoints (points) {
   if (!Array.isArray(points)) return []
   const ring = points.filter(point => point && Number.isFinite(point.lng) && Number.isFinite(point.lat))
   if (ring.length < 2) return ring
   const first = ring[0]
   const last = ring[ring.length - 1]
   if (isSameWktPoint(first, last)) {
      return ring.slice(0, -1)
   }
   return ring
}
function isSelfIntersectingPolygon (points) {
   const ring = normalizePolygonPoints(points)
   if (ring.length < 4) return false
   const epsilon = 1e-12
   const orientation = (a, b, c) => {
      const value = (b.lat - a.lat) * (c.lng - b.lng) - (b.lng - a.lng) * (c.lat - b.lat)
      if (Math.abs(value) < epsilon) return 0
      return value > 0 ? 1 : 2
   }
   const onSegment = (a, b, c) =>
      b.lng <= Math.max(a.lng, c.lng) + epsilon &&
      b.lng + epsilon >= Math.min(a.lng, c.lng) &&
      b.lat <= Math.max(a.lat, c.lat) + epsilon &&
      b.lat + epsilon >= Math.min(a.lat, c.lat)
   const segmentsIntersect = (p1, q1, p2, q2) => {
      const o1 = orientation(p1, q1, p2)
      const o2 = orientation(p1, q1, q2)
      const o3 = orientation(p2, q2, p1)
      const o4 = orientation(p2, q2, q1)
      if (o1 !== o2 && o3 !== o4) return true
      if (o1 === 0 && onSegment(p1, p2, q1)) return true
      if (o2 === 0 && onSegment(p1, q2, q1)) return true
      if (o3 === 0 && onSegment(p2, p1, q2)) return true
      if (o4 === 0 && onSegment(p2, q1, q2)) return true
      return false
   }
   const count = ring.length
   for (let i = 0; i < count; i += 1) {
      const p1 = ring[i]
      const q1 = ring[(i + 1) % count]
      for (let j = i + 1; j < count; j += 1) {
         const p2 = ring[j]
         const q2 = ring[(j + 1) % count]
         const isAdjacent = j === i || j === i + 1 || (i === 0 && j === count - 1)
         if (isAdjacent) continue
         if (segmentsIntersect(p1, q1, p2, q2)) return true
      }
   }
   return false
}
function buildPolygonWktFromPoints (points) {
   if (!Array.isArray(points) || points.length < 3) return null
   const ring = points
      .map(resolveLngLatPoint)
      .filter(Boolean)
      .map(point => ({ lng: point.lng, lat: point.lat }))
   if (ring.length < 3) return null
   const first = ring[0]
   const last = ring[ring.length - 1]
   if (!isSameWktPoint(first, last)) {
      ring.push({ ...first })
   }
   const coordText = ring
      .map(point => {
         const lng = formatWktNumber(point.lng)
         const lat = formatWktNumber(point.lat)
         if (lat === null || lng === null) return null
         return `${lng} ${lat}`
      })
      .filter(Boolean)
      .join(', ')
   if (!coordText) return null
   return `POLYGON((${coordText}))`
}
function buildEllipseWktFromMeta (center, semiMajor, semiMinor) {
   if (!viewer) return null
   const resolvedCenter = resolveLngLatPoint(center)
   if (!resolvedCenter) return null
   if (!Number.isFinite(Number(semiMajor)) || !Number.isFinite(Number(semiMinor))) return null
   const centerCartesian = Cesium.Cartesian3.fromDegrees(
      resolvedCenter.lng,
      resolvedCenter.lat,
      resolvedCenter.height || 0
   )
   const positions = buildEllipsePositions(centerCartesian, Number(semiMajor), Number(semiMinor))
   if (!positions.length) return null
   const points = positions
      .map(item => {
         const converted = cartesian3Convert(item, viewer)
         return { lng: converted.longitude, lat: converted.latitude }
      })
      .filter(Boolean)
   return buildPolygonWktFromPoints(points)
}
function resolveShapePolygonsWkt () {
   const polygons = []
   shapeList.value.forEach(shape => {
      if (!shape) return
      if (shape.drawType === 'buffer') {
         const radii = Array.isArray(shape?.meta?.bufferRadii)
            ? shape.meta.bufferRadii.filter(item => Number.isFinite(item) && item > 0)
            : []
         const maxRadius = radii.length ? Math.max(...radii) : null
         const wkt = buildEllipseWktFromMeta(shape?.meta?.center, maxRadius, maxRadius)
         if (wkt) polygons.push(wkt)
         return
      }
      if (shape.drawType === 'ellipse') {
         const wkt = buildEllipseWktFromMeta(shape?.meta?.center, shape?.meta?.semiMajor, shape?.meta?.semiMinor)
         if (wkt) {
            polygons.push(wkt)
            return
         }
      }
      const points = getShapeDisplayPoints(shape)
      const wkt = buildPolygonWktFromPoints(points)
      if (wkt) polygons.push(wkt)
   })
   return polygons
}
async function renderDeviceRanges (rows) {
   if (!viewer) return
   deviceRangeRenderToken += 1
   const renderToken = deviceRangeRenderToken
   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)
      }
   }
   for (const [id, entity] of deviceIconEntityMap.entries()) {
      if (!nextIds.has(id)) {
         viewer.entities.remove(entity)
         deviceIconEntityMap.delete(id)
      }
   }
   const pendingRows = selectedRows.filter(row => {
@@ -582,13 +687,21 @@
   if (!pendingRows.length) return
   const primitives = await Promise.all(
      pendingRows.map(async row => {
         const primitive = await createDeviceRangePrimitive(row)
         return { row, primitive }
         const position = getDeviceLngLat(row)
         if (!position) return { row, primitive: null, position: null }
         const rangeMeters = normalizeDeviceRange(row.effectiveRangeKm)
         const primitive = await createDeviceRangePrimitive(viewer, position, rangeMeters)
         return { row, primitive, position }
      })
   )
   primitives.forEach(({ row, primitive }) => {
   if (renderToken !== deviceRangeRenderToken) return
   primitives.forEach(({ row, primitive, position }) => {
      if (position) {
         addDeviceIconEntity(row, position)
      }
      if (!primitive) return
      const id = String(row?.id)
      if (!nextIds.has(id)) return
      deviceRangePrimitiveMap.set(id, primitive)
      viewer.scene.primitives.add(primitive)
   })
@@ -598,8 +711,9 @@
   if (!viewer) return
   const position = getDeviceLngLat(device)
   if (!position) return
   const height = await resolveDeviceTerrainHeight(position)
   const destination = Cesium.Cartesian3.fromDegrees(position.lng, position.lat, height + device.effectiveRangeKm * 3)
   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: {
@@ -614,6 +728,23 @@
function clearShapeDisplay () {
   if (!shapeDisplaySource) return
   shapeDisplaySource.entities.removeAll()
}
function addDeviceIconEntity (device, position) {
   if (!viewer || !position) return
   const id = String(device?.id ?? '')
   if (!id || deviceIconEntityMap.has(id)) return
   const entity = viewer.entities.add({
      position: Cesium.Cartesian3.fromDegrees(position.lng, position.lat, position.height || 0),
      billboard: {
         image: equipmentIcon,
         width: 28,
         height: 38,
         verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
         heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
      },
   })
   deviceIconEntityMap.set(id, entity)
}
function normalizeShapePoint (point) {
@@ -724,6 +855,10 @@
                     material: style.outline,
                  },
               })
               addBufferRadiusLabelEntity(dataSource, centerCartesian, radius, {
                  name: 'shape-display',
                  customData: { drawType: 'buffer', label: 'radius' },
               })
            })
         }
         return
@@ -752,8 +887,72 @@
   })
}
function pushRadiusBoundingPoints (positions, center, radius) {
   if (!center || !Number.isFinite(Number(radius)) || radius <= 0) return
   const resolved = resolveLngLatPoint(center) || {}
   const lng = Number(resolved.lng ?? resolved.longitude)
   const lat = Number(resolved.lat ?? resolved.latitude)
   if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
   const baseLat = Cesium.Math.toRadians(lat)
   const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius
   const deltaLat = radius / earthRadius
   const deltaLng = radius / (earthRadius * Math.max(Math.cos(baseLat), 0.000001))
   const points = [
      { lng: Cesium.Math.toDegrees(Cesium.Math.toRadians(lng) + deltaLng), lat },
      { lng: Cesium.Math.toDegrees(Cesium.Math.toRadians(lng) - deltaLng), lat },
      { lng, lat: Cesium.Math.toDegrees(baseLat + deltaLat) },
      { lng, lat: Cesium.Math.toDegrees(baseLat - deltaLat) },
   ]
   points.forEach(point => {
      positions.push(Cesium.Cartesian3.fromDegrees(point.lng, point.lat, resolved.height || 0))
   })
}
function collectShapeBoundingPositions () {
   const positions = []
   if (!viewer || !shapeList.value.length) return positions
   shapeList.value.forEach(shape => {
      let points = getShapeDisplayPoints(shape)
      if (!Array.isArray(points) || !points.length) {
         points = resolveControlPoints(shape?.meta, shape?.drawType)
      }
      if (Array.isArray(points) && points.length) {
         points.forEach(point => {
            const resolved = resolveLngLatPoint(point)
            const normalized = normalizeShapePoint(resolved)
            if (!normalized) return
            positions.push(Cesium.Cartesian3.fromDegrees(normalized.lng, normalized.lat, normalized.height))
         })
      }
      if (shape?.drawType === 'buffer' && shape?.meta?.center && Array.isArray(shape?.meta?.bufferRadii)) {
         const maxRadius = Math.max(...shape.meta.bufferRadii.filter(item => Number.isFinite(item)))
         pushRadiusBoundingPoints(positions, shape.meta.center, maxRadius)
      }
      if (shape?.drawType === 'ellipse' && shape?.meta?.center) {
         const major = Number(shape?.meta?.semiMajor)
         const minor = Number(shape?.meta?.semiMinor)
         const radius = Number.isFinite(major) && Number.isFinite(minor) ? Math.max(major, minor) : null
         pushRadiusBoundingPoints(positions, shape.meta.center, radius)
      }
   })
   return positions
}
function fitViewToShapeList () {
   if (!viewer || !shapeList.value.length) return
   const positions = collectShapeBoundingPositions()
   if (positions.length < 2) return
   const boundingSphere = Cesium.BoundingSphere.fromPoints(positions)
   const range = Math.max(boundingSphere.radius * 2.4, 200)
   viewer.camera.flyToBoundingSphere(boundingSphere, {
      duration: 0,
      offset: new Cesium.HeadingPitchRange(viewer.camera.heading, Cesium.Math.toRadians(-90), range),
   })
}
// 绘制完成回调
const drawFinished = async data => {
const drawFinished = async (data, options = {}) => {
   const shouldContinueDraw = options?.continueDraw || activeToolMode.value === 'draw'
   const positions = Array.isArray(data?.positions) ? data.positions : data
   if (!Array.isArray(positions) || positions.length < 3) {
      pointList = []
@@ -768,6 +967,16 @@
      const val = cartesian3Convert(item, viewer)
      return { ...val, lng: val.longitude, lat: val.latitude }
   })
   if (currentShapeType.value === 'polygon' && isSelfIntersectingPolygon(pointList)) {
      ElMessage.warning('多边形存在自相交,请调整后再保存')
      if (activeToolMode.value === 'draw') {
         pointList = []
         activeShapeId.value = null
         clearActiveTool()
         startDraw('polygon')
      }
      return
   }
   const controlPoints = resolveControlPoints(meta, currentShapeType.value)
   if (!activeAreaType.value) {
      const detectAreaType = getDetectAreaTypeKey()
@@ -832,6 +1041,12 @@
      shapeList.value.splice(targetIndex, 1, shapePayload)
   }
   renderShapeList()
   if (shouldContinueDraw && !readonly.value) {
      activeShapeId.value = null
      pointList = []
      clearActiveTool()
      startDraw(currentShapeType.value || 'polygon')
   }
}
function getDetectAreaTypeKey () {
@@ -932,11 +1147,13 @@
   const positions = activeTool?.getPositions?.()
   if (Array.isArray(positions) && positions.length >= 3) {
      suppressTypePanelOnce.value = true
      drawFinished(positions)
      drawFinished(positions, { continueDraw: true })
      return
   }
   if (positions?.positions?.length >= 3) {
      suppressTypePanelOnce.value = true
      drawFinished(positions)
      drawFinished(positions, { continueDraw: true })
      return
   }
   clearActiveTool()
}
@@ -1006,15 +1223,17 @@
function handleClearShape () {
   if (readonly.value) return
   clearActiveTool()
   viewEntity && viewer?.entities?.remove(viewEntity)
   viewEntity = null
   pointList = []
   shapeList.value = []
   activeShapeId.value = null
   activeAreaType.value = ''
   hideTypePanel()
   clearShapeDisplay()
   clearActiveTool()
   if (viewer && currentShapeType.value) {
      startDraw(currentShapeType.value)
   }
}
function handleShapeEdit (row) {
@@ -1051,6 +1270,7 @@
   if (!viewer) return
   if (shapeList.value.length) {
      renderShapeList()
      fitViewToShapeList()
      return
   }
   if (!formData.value?.geom) return
@@ -1070,19 +1290,108 @@
}
// 获取派出所列表
async function getPoliceStationList () {
   const res = await fwPoliceStationListApi()
   policeStationOptions.value = res?.data?.data ?? []
async function getPoliceStationList (keyword = '') {
   if (!visible.value) return
   policeStationLoading.value = true
   try {
      const res = await fwPoliceStationListApi({
         stationName: keyword || undefined,
      })
      const list = res?.data?.data ?? []
      policeStationOptions.value = list
      if (!keyword) {
         policeStationAllOptions.value = list
      }
      await nextTick()
      if (!keyword) {
         const matched = policeStationOptions.value.find(
            item => String(item.id) === String(formData.value.policeStationId)
         )
         if (!matched) {
            formData.value.policeStationId = ''
         }
      }
   } finally {
      policeStationLoading.value = false
   }
}
function handlePoliceStationSearch (query) {
   policeStationSearchKeyword.value = (query || '').trim()
   if (!policeStationSearchKeyword.value) {
      policeStationOptions.value = [...policeStationAllOptions.value]
      return
   }
   if (policeStationSearchTimer.value) {
      clearTimeout(policeStationSearchTimer.value)
   }
   policeStationSearchTimer.value = setTimeout(() => {
      policeStationSearchTimer.value = null
      void getPoliceStationList(policeStationSearchKeyword.value)
   }, 300)
}
// 获取设备列表
async function getDeviceList () {
   if (deviceOptions.value.length) return
   const res = await fwDeviceListApi({ isAreaSelect: 1, areaId: formData.value.id, isTrack: 1 })
   deviceOptions.value = res?.data?.data ?? []
   if (dialogMode.value === 'view') {
      deviceOptions.value = deviceOptions.value.filter(item => formData.value.deviceIds.includes(item.id))
function queueDeviceListRefresh () {
   if (!visible.value) return
   if (deviceListTimer) {
      clearTimeout(deviceListTimer)
   }
   deviceListTimer = setTimeout(() => {
      deviceListTimer = null
      void getDeviceListByPolygons()
   }, 200)
}
async function getDeviceListByPolygons () {
   if (!visible.value) return
   const polygons = resolveShapePolygonsWkt()
   if (!polygons.length) {
      clearDeviceListState()
      return
   }
   deviceListRequestToken += 1
   const requestToken = deviceListRequestToken
   const res = await fwDeviceListByPolygonsApi({
      areaId: dialogMode.value === 'add' ? '' : formData.value.id,
      isAreaSelect: 1,
      isTrack: 1,
      polygons,
   })
   if (requestToken !== deviceListRequestToken) return
   deviceOptions.value = res?.data?.data ?? []
   if (dialogMode.value === 'view' && formData.value.deviceIds) {
      const targetIds = formData.value.deviceIds.split(',')
      deviceOptions.value = deviceOptions.value.filter(item => targetIds.includes(String(item.id)))
   }
   await nextTick()
   const prevSuppressDeviceFly = suppressDeviceFly.value
   suppressDeviceFly.value = true
   syncDeviceSelection()
   suppressDeviceFly.value = prevSuppressDeviceFly
   if (readonly.value) {
      await renderDeviceRanges(deviceOptions.value)
   }
}
// 设备上图(新增/编辑用)
async function getDeviceListForMapDisplay () {
   if (!visible.value || !viewer) return
   deviceMapRequestToken += 1
   const requestToken = deviceMapRequestToken
   const res = await fwDeviceListByPolygonsApi({
      areaId: dialogMode.value === 'add' ? '' : formData.value.id,
      isAreaSelect: 1,
      isTrack: 1,
   })
   if (requestToken !== deviceMapRequestToken) return
   const list = res?.data?.data ?? []
   if (!list.length) {
      clearDeviceRangePrimitives()
      clearDeviceIconEntities()
      return
   }
   await renderDeviceRanges(list)
}
// 关联设备变更
@@ -1090,37 +1399,41 @@
   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)
   if (suppressDeviceFly.value) {
      lastSelectedDeviceIds = [...ids]
      return
   }
   const prevSet = new Set(lastSelectedDeviceIds)
   const addedRow = rows.find(row => !prevSet.has(row.id))
   lastSelectedDeviceIds = [...ids]
   if (addedRow) {
      void flyToDevice(addedRow)
   }
}
function handleDeviceRowClick (row) {
   if (readonly.value) {
      renderDeviceRangeSingle(row)
      void flyToDevice(row)
      return
   }
   renderDeviceRanges(selectedDeviceRows.value)
   void flyToDevice(row)
   const isSelected = selectedDeviceRows.value.some(item => String(item?.id) === String(row?.id))
   if (isSelected) {
      void flyToDevice(row)
   }
}
// 同步选择状态
function syncDeviceSelection () {
   if (isDialogInitializing.value && !deviceOptions.value.length) return
   const rows = []
   const arr = formData.value.deviceIds.split(',')
   const arr = formData.value.deviceIds ? formData.value.deviceIds.split(',') : []
   deviceOptions.value.forEach(row => {
      if (arr.includes(row.id)) {
      if (arr.includes(String(row.id))) {
         rows.push(row)
      }
   })
   selectedDeviceRows.value = rows
   if (readonly.value) {
      renderDeviceRanges(selectedDeviceRows.value)
      return
   }
   formData.value.deviceIds = rows.map(item => item.id).join(',')
   if (!deviceTableRef.value) return
   deviceTableRef.value.clearSelection()
   rows.forEach(row => {
@@ -1130,9 +1443,18 @@
// 打开弹框
async function open ({ mode, row } = {}) {
   isDialogInitializing.value = true
   dialogMode.value = mode || 'add'
   formData.value = dialogMode.value === 'add' ? initForm() : row
   selectedDeviceRows.value = []
   deviceOptions.value = []
   policeStationOptions.value = []
   policeStationAllOptions.value = []
   policeStationSearchKeyword.value = ''
   if (policeStationSearchTimer.value) {
      clearTimeout(policeStationSearchTimer.value)
      policeStationSearchTimer.value = null
   }
   shapeList.value = []
   activeShapeId.value = null
   activeAreaType.value = ''
@@ -1141,31 +1463,60 @@
   initMap()
   clearShapeDisplay()
   clearDeviceRangePrimitives()
   clearDeviceIconEntities()
   viewEntity && viewer?.entities?.remove(viewEntity)
   viewEntity = null
   clearActiveTool()
   await getDeviceList()
   if (dialogMode.value === 'add') {
      // default no draw mode
      mapRef.value?.zoomToAdminBoundary?.()
      handleDraw('polygon')
   } else if (dialogMode.value === 'edit') {
      await loadDetail()
      viewPolygon()
      activeShapeId.value = null
      pointList = []
      const firstShape = shapeList.value[0]
      const initialType = firstShape?.drawType || 'polygon'
      currentShapeType.value = initialType
      if (initialType === 'buffer') {
         activeAreaType.value = bufferAreaTypeValue.split(',')
      } else if (firstShape?.areaType) {
         activeAreaType.value = firstShape.areaType
      } else {
         const defaultAreaType = getDetectAreaTypeKey()
         if (defaultAreaType) {
            activeAreaType.value = defaultAreaType
         }
      }
      clearActiveTool()
      startDraw(initialType)
   } else {
      await loadDetail()
      viewPolygon()
   }
   await nextTick()
   suppressDeviceFly.value = true
   syncDeviceSelection()
   if (!readonly.value) {
      renderDeviceRanges(selectedDeviceRows.value)
   lastSelectedDeviceIds = selectedDeviceRows.value.map(row => row.id)
   await nextTick()
   suppressDeviceFly.value = false
   isDialogInitializing.value = false
   queueDeviceListRefresh()
   if (dialogMode.value === 'add' || dialogMode.value === 'edit') {
      void getDeviceListForMapDisplay()
   }
   void getPoliceStationList()
}
onMounted(() => {
   getPoliceStationList()
})
onBeforeUnmount(() => {
   if (deviceListTimer) {
      clearTimeout(deviceListTimer)
      deviceListTimer = null
   }
   if (policeStationSearchTimer.value) {
      clearTimeout(policeStationSearchTimer.value)
      policeStationSearchTimer.value = null
   }
   hideTypePanel()
})
@@ -1227,44 +1578,31 @@
   right: 16px;
   display: flex;
   flex-direction: column;
   gap: 10px;
   padding: 10px 8px;
   background: rgba(19, 28, 36, 0.75);
   gap: 14px;
   padding: 32px 14px;
   background: rgba(26, 25, 25, 0.6);
   border-radius: 8px 8px 8px 8px;
   backdrop-filter: blur(4px);
   border-radius: 8px;
   box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2);
}
.tool-btn {
   padding: 0;
   padding: 0 !important;
   width: 46px;
   height: 46px;
   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;
   border: none !important;
   box-shadow: none;
   cursor: pointer;
   transition: all 0.2s ease;
   overflow: hidden;
   background: transparent !important;
   box-sizing: border-box;
}
.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-btn.active {
   border: 2px solid #FFFFFF !important;
}
.tool-icon {
@@ -1272,8 +1610,8 @@
}
.tool-icon-image {
   width: 36px;
   height: 36px;
   width: 100%;
   height: 100%;
}
.tool-icon.polygon {