| | |
| | | :terrain="true" |
| | | :layer-mode="4" |
| | | :boundary="false" |
| | | :zoom-to-boundary="dialogMode === 'add'" |
| | | /> |
| | | </div> |
| | | <div class="right-container"> |
| | |
| | | <div class="detail-title">场景详情</div> |
| | | <el-row> |
| | | <el-col :span="24"> |
| | | <div class="label">场景名称</div> |
| | | <div class="label">配置名称</div> |
| | | <div class="val">{{ formData.sceneName }}</div> |
| | | </el-col> |
| | | <el-col :span="24"> |
| | |
| | | </el-table> |
| | | </div> |
| | | </div> |
| | | <div class="detail-title">设备列表</div> |
| | | <div class="command-table-container"> |
| | | <div class="command-table-content"> |
| | | <el-table |
| | | class="command-table" |
| | | :data="deviceList" |
| | | :loading="deviceLoading" |
| | | row-key="id" |
| | | > |
| | | <el-table-column prop="deviceName" show-overflow-tooltip label="设备名称" /> |
| | | <el-table-column prop="deviceType" show-overflow-tooltip label="设备类型"> |
| | | <template v-slot="{ row }"> |
| | | {{ getDictLabel(row.deviceType, dictObj.deviceType) }} |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-form |
| | |
| | | :disabled="readonly" |
| | | label-width="96px" |
| | | > |
| | | <el-form-item label="场景名称" prop="sceneName"> |
| | | <el-form-item label="配置名称" prop="sceneName"> |
| | | <el-input |
| | | class="command-input" |
| | | v-model="formData.sceneName" |
| | |
| | | </el-table> |
| | | </el-form-item> |
| | | </div> |
| | | |
| | | <div class="detail-title">设备列表</div> |
| | | <div class="command-table-container"> |
| | | <div class="command-table-content"> |
| | | <el-table |
| | | class="command-table" |
| | | :data="deviceList" |
| | | :loading="deviceLoading" |
| | | row-key="id" |
| | | > |
| | | <el-table-column prop="deviceName" show-overflow-tooltip label="设备名称" /> |
| | | <el-table-column prop="deviceType" show-overflow-tooltip label="设备类型"> |
| | | <template v-slot="{ row }"> |
| | | {{ getDictLabel(row.deviceType, dictObj.deviceType) }} |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | </div> |
| | | </el-form> |
| | | |
| | | <div class="footer"> |
| | |
| | | import CommonCesiumMap from '@/components/map-container/common-cesium-map.vue' |
| | | import * as Cesium from 'cesium' |
| | | import { fwAreaDivideDetailApi, fwAreaDivideListApi } from '../partition/partitionApi' |
| | | import { buildEllipsePositions } from '@/utils/cesium/shapeTools' |
| | | import { fwDeviceListApi } from '@/views/basicManage/deviceStock/fwDevice' |
| | | import { |
| | | addBufferRadiusLabelEntity, |
| | | buildEllipsePositions, |
| | | } from '@/utils/cesium/shapeTools' |
| | | import { cartesian3Convert } from '@/utils/cesium/mapUtil' |
| | | import { |
| | | createDeviceRangePrimitive, |
| | | getDeviceLngLat, |
| | | normalizeDeviceRange, |
| | | } from '@/utils/cesium/deviceRange' |
| | | import { saveOperationLog } from '@ztzf/apis' |
| | | import { useRoute } from 'vue-router' |
| | | import { AREA_TYPE_STYLE_MAP, BUFFER_LEVEL_STYLES, DEFAULT_AREA_STYLE } from '@ztzf/constants' |
| | | import equipmentIcon from '@/assets/images/dataCockpit/map/equipment.png' |
| | | |
| | | const initForm = () => ({ |
| | | sceneName: '', // 场景名称 |
| | | sceneName: '', // 配置名称 |
| | | sceneType: '', // 场景类型 |
| | | deviceMode: '', // 设备模式 |
| | | areaDivideIds: '', // 关联区域ID |
| | |
| | | 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 titleEnum = ref({ edit: '编辑', view: '查看', add: '新增' }) |
| | |
| | | const searchName = ref('') |
| | | const areaList = ref([]) // 关联区域列表 |
| | | const selectedAreaRows = ref([]) //选中列表 |
| | | let lastSelectedAreaIds = [] |
| | | const deviceList = ref([]) // 设备列表 |
| | | const deviceLoading = ref(false) |
| | | const dictObj = inject('dictObj') |
| | | const mapRef = ref(null) |
| | | const route = useRoute() |
| | | let viewer |
| | | let areaDisplaySource |
| | | const deviceRangePrimitiveMap = new Map() |
| | | const deviceIconEntityMap = new Map() |
| | | let deviceRangeRenderToken = 0 |
| | | |
| | | const rules = { |
| | | sceneName: fieldRules(true, 50), |
| | |
| | | areaList.value = res?.data?.data ?? [] |
| | | } |
| | | |
| | | async function loadDeviceListByAreaIds(areaIdsKey) { |
| | | if (!areaIdsKey) { |
| | | deviceList.value = [] |
| | | clearDeviceRangePrimitives() |
| | | clearDeviceIconEntities() |
| | | return |
| | | } |
| | | deviceLoading.value = true |
| | | try { |
| | | const res = await fwDeviceListApi({ areaIds: areaIdsKey }) |
| | | deviceList.value = res?.data?.data ?? [] |
| | | await nextTick() |
| | | renderDeviceRanges(deviceList.value) |
| | | } finally { |
| | | deviceLoading.value = false |
| | | } |
| | | } |
| | | |
| | | // 关联区域变更 |
| | | async function handleAreaSelectionChange(rows) { |
| | | const prevIds = new Set(lastSelectedAreaIds) |
| | | const selectedRows = await ensureAreaExtList(rows) |
| | | selectedAreaRows.value = selectedRows |
| | | const ids = selectedRows.map(item => item.id) |
| | | lastSelectedAreaIds = [...ids] |
| | | formData.value.areaDivideIds = ids.join(',') |
| | | formData.value.areaCount = ids.length |
| | | if (ids.some(id => !prevIds.has(id))) { |
| | | await nextTick() |
| | | fitViewToSelectedAreas() |
| | | } |
| | | } |
| | | |
| | | // 渲染面 |
| | |
| | | areaDisplaySource.entities.removeAll() |
| | | } |
| | | |
| | | function clearDeviceRangePrimitives() { |
| | | if (!viewer || !deviceRangePrimitiveMap.size) return |
| | | for (const primitive of deviceRangePrimitiveMap.values()) { |
| | | viewer.scene.primitives.remove(primitive) |
| | | } |
| | | deviceRangePrimitiveMap.clear() |
| | | } |
| | | |
| | | function clearDeviceIconEntities() { |
| | | if (!viewer || !deviceIconEntityMap.size) return |
| | | for (const entity of deviceIconEntityMap.values()) { |
| | | viewer.entities.remove(entity) |
| | | } |
| | | deviceIconEntityMap.clear() |
| | | } |
| | | |
| | | function normalizeShapePoint(point) { |
| | | if (!point) return null |
| | | const lng = point?.lng ?? point?.longitude |
| | |
| | | const height = Number.isFinite(Number(point?.height)) ? Number(point.height) : 0 |
| | | if (!Number.isFinite(Number(lng)) || !Number.isFinite(Number(lat))) return null |
| | | return { lng: Number(lng), lat: Number(lat), height } |
| | | } |
| | | |
| | | 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 buildShapePositions(points = []) { |
| | |
| | | |
| | | function getAreaTypeStyle(areaType) { |
| | | return AREA_TYPE_STYLE_MAP?.[String(areaType)] || DEFAULT_AREA_STYLE |
| | | } |
| | | |
| | | function resolveControlPoints(meta, drawType) { |
| | | if (!meta) return [] |
| | | if (Array.isArray(meta.controlPoints) && meta.controlPoints.length) { |
| | | return meta.controlPoints.map(resolveLngLatPoint).filter(Boolean) |
| | | } |
| | | if (drawType === 'buffer') { |
| | | return [resolveLngLatPoint(meta.center)].filter(Boolean) |
| | | } |
| | | if (drawType === 'ellipse') { |
| | | return [meta.center, meta.majorPoint, meta.minorPoint].map(resolveLngLatPoint).filter(Boolean) |
| | | } |
| | | return [] |
| | | } |
| | | |
| | | function parseGeomJson(geomJson) { |
| | |
| | | material: style.outline, |
| | | }, |
| | | }) |
| | | addBufferRadiusLabelEntity(dataSource, centerCartesian, radius, { |
| | | name: 'scene-area-display', |
| | | customData: { drawType: 'buffer', label: 'radius' }, |
| | | }) |
| | | }) |
| | | return |
| | | } |
| | |
| | | }) |
| | | } |
| | | |
| | | 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 collectSelectedAreaPositions() { |
| | | const positions = [] |
| | | if (!viewer || !selectedAreaRows.value.length) return positions |
| | | selectedAreaRows.value.forEach(area => { |
| | | const shapes = resolveAreaShapes(area) |
| | | shapes.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 fitViewToSelectedAreas() { |
| | | if (!viewer || !selectedAreaRows.value.length) return |
| | | const positions = collectSelectedAreaPositions() |
| | | 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), |
| | | }) |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | |
| | | async function renderDeviceRanges(rows) { |
| | | if (!visible.value) return |
| | | if (!viewer) initMap() |
| | | 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 => { |
| | | 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, position: null } |
| | | const rangeMeters = normalizeDeviceRange(row.effectiveRangeKm) |
| | | const primitive = await createDeviceRangePrimitive(viewer, position, rangeMeters) |
| | | return { row, primitive, position } |
| | | }) |
| | | ) |
| | | 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) |
| | | }) |
| | | } |
| | | |
| | | watch(() => selectedAreaRows.value, renderSelectedAreas) |
| | | const deviceAreaIdsKey = computed(() => |
| | | selectedAreaRows.value.map(item => item?.id).filter(Boolean).join(',') |
| | | ) |
| | | watch( |
| | | () => deviceAreaIdsKey.value, |
| | | val => { |
| | | loadDeviceListByAreaIds(val) |
| | | } |
| | | ) |
| | | watch( |
| | | () => deviceList.value, |
| | | val => { |
| | | renderDeviceRanges(val) |
| | | } |
| | | ) |
| | | watch( |
| | | () => visible.value, |
| | | val => { |
| | | if (!val) clearAreaDisplay() |
| | | if (!val) { |
| | | clearAreaDisplay() |
| | | clearDeviceRangePrimitives() |
| | | clearDeviceIconEntities() |
| | | } |
| | | } |
| | | ) |
| | | |
| | |
| | | selectedAreaRows.value = ensuredRows |
| | | if (!ensuredRows.length) return |
| | | formData.value.areaCount = ensuredRows.length |
| | | lastSelectedAreaIds = ensuredRows.map(row => row.id) |
| | | } |
| | | |
| | | // 初始化地图实例 |
| | |
| | | await nextTick() |
| | | initMap() |
| | | await getAreaList() |
| | | if (dialogMode.value !== 'add') { |
| | | if (dialogMode.value === 'add') { |
| | | mapRef.value?.zoomToAdminBoundary?.() |
| | | } else { |
| | | await loadDetail() |
| | | } |
| | | await nextTick() |
| | | await syncAreaSelection() |
| | | await nextTick() |
| | | fitViewToSelectedAreas() |
| | | } |
| | | defineExpose({ |
| | | open, |