Compare commits
No commits in common. "723e7ddf4044ef82bf3b7fb3457b14ce195e36c3" and "89ed8896123cf9a11966c0c54ae92dc6c7f6edef" have entirely different histories.
723e7ddf40
...
89ed889612
@ -95,86 +95,6 @@ export function createEntityService(deps) {
|
|||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加广告牌(Billboard)实体到地图
|
|
||||||
* @param {Object} opts - 配置选项
|
|
||||||
* @param {string} [opts.id] - 实体 ID,不提供则自动生成
|
|
||||||
* @param {string} [opts.layerId] - 图层 ID,不提供则使用默认图层
|
|
||||||
* @param {Array<number>} opts.position - 位置 [经度, 纬度] 或 [经度, 纬度, 高度],高度默认为 0
|
|
||||||
* @param {string} opts.image - 图片 URL 或路径
|
|
||||||
* @param {number} [opts.width=32] - 图片宽度(像素)
|
|
||||||
* @param {number} [opts.height=32] - 图片高度(像素)
|
|
||||||
* @param {boolean} [opts.clampToGround=true] - 是否贴地
|
|
||||||
* @param {Cesium.VerticalOrigin} [opts.verticalOrigin] - 垂直对齐方式
|
|
||||||
* @param {Array<number>|Cesium.Cartesian2} [opts.pixelOffset] - 像素偏移 [x, y]
|
|
||||||
* @param {number} [opts.disableDepthTestDistance] - 禁用深度测试的距离
|
|
||||||
* @param {Object} [opts.properties] - 自定义属性
|
|
||||||
* @returns {Promise<string>} 返回实体 ID
|
|
||||||
*/
|
|
||||||
async addBillboard(opts) {
|
|
||||||
const o = opts || {}
|
|
||||||
|
|
||||||
// 验证必需参数
|
|
||||||
if (!Array.isArray(o.position) || o.position.length < 2) {
|
|
||||||
throw new Error('addBillboard 需要提供 position 参数 [经度, 纬度] 或 [经度, 纬度, 高度]')
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = o.image || o.icon
|
|
||||||
if (!image) {
|
|
||||||
throw new Error('addBillboard 需要提供 image 或 icon 参数')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保 position 包含高度,如果没有则默认为 0
|
|
||||||
const position = o.position.length === 2
|
|
||||||
? [o.position[0], o.position[1], 0]
|
|
||||||
: o.position
|
|
||||||
|
|
||||||
const ds = await this._ensureVectorLayer(o.layerId)
|
|
||||||
const id = o.id || uid('billboard')
|
|
||||||
|
|
||||||
// 处理像素偏移
|
|
||||||
let pixelOffset
|
|
||||||
if (o.pixelOffset instanceof Cesium.Cartesian2) {
|
|
||||||
pixelOffset = o.pixelOffset
|
|
||||||
} else if (Array.isArray(o.pixelOffset)) {
|
|
||||||
pixelOffset = new Cesium.Cartesian2(
|
|
||||||
o.pixelOffset[0] || 0,
|
|
||||||
o.pixelOffset[1] || 0
|
|
||||||
)
|
|
||||||
} else if (o.pixelOffset && typeof o.pixelOffset === 'object') {
|
|
||||||
pixelOffset = new Cesium.Cartesian2(
|
|
||||||
o.pixelOffset.x || 0,
|
|
||||||
o.pixelOffset.y || 0
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
pixelOffset = new Cesium.Cartesian2(0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ent = new Cesium.Entity({
|
|
||||||
id,
|
|
||||||
position: degToCartesian(position),
|
|
||||||
billboard: {
|
|
||||||
image,
|
|
||||||
width: o.width || 32,
|
|
||||||
height: o.height || 32,
|
|
||||||
verticalOrigin: o.verticalOrigin || Cesium.VerticalOrigin.BOTTOM,
|
|
||||||
heightReference:
|
|
||||||
o.clampToGround === false
|
|
||||||
? Cesium.HeightReference.NONE
|
|
||||||
: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
||||||
disableDepthTestDistance:
|
|
||||||
typeof o.disableDepthTestDistance === 'number'
|
|
||||||
? o.disableDepthTestDistance
|
|
||||||
: Number.POSITIVE_INFINITY,
|
|
||||||
pixelOffset,
|
|
||||||
},
|
|
||||||
properties: o.properties || {},
|
|
||||||
})
|
|
||||||
|
|
||||||
ds.entities.add(ent)
|
|
||||||
return id
|
|
||||||
},
|
|
||||||
|
|
||||||
removeEntity(entityId) {
|
removeEntity(entityId) {
|
||||||
if (!entityId) return false
|
if (!entityId) return false
|
||||||
for (const id in store.layers) {
|
for (const id in store.layers) {
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* 应急力量相关 API
|
|
||||||
* @module api/emergencyForce
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { request } from '@shared/utils/request'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取应急力量点位列表
|
|
||||||
*
|
|
||||||
* @param {Object} [config={}] - 额外的 axios 配置
|
|
||||||
* @param {AbortSignal} [config.signal] - 用于取消请求的信号
|
|
||||||
* @param {Object} [config.params] - 查询参数
|
|
||||||
* @returns {Promise<Object>} 返回应急力量点位数据
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 基本用法
|
|
||||||
* const data = await fetchEmergencyForceList()
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 带取消信号
|
|
||||||
* const controller = new AbortController()
|
|
||||||
* const data = await fetchEmergencyForceList({ signal: controller.signal })
|
|
||||||
* // 取消请求
|
|
||||||
* controller.abort()
|
|
||||||
*/
|
|
||||||
export function fetchEmergencyForceList(config = {}) {
|
|
||||||
return request({
|
|
||||||
url: '/snow-ops-platform/xqyjllb/list',
|
|
||||||
method: 'GET',
|
|
||||||
...config
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 rid 获取应急力量详细信息
|
|
||||||
*
|
|
||||||
* @param {string|number} rid - 应急力量记录的唯一标识
|
|
||||||
* @param {Object} [config={}] - 额外的 axios 配置
|
|
||||||
* @param {AbortSignal} [config.signal] - 用于取消请求的信号
|
|
||||||
* @returns {Promise<Object>} 返回应急力量详细数据
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 基本用法
|
|
||||||
* const detail = await fetchEmergencyForceDetail('123')
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 带取消信号
|
|
||||||
* const controller = new AbortController()
|
|
||||||
* const detail = await fetchEmergencyForceDetail('123', { signal: controller.signal })
|
|
||||||
* // 取消请求
|
|
||||||
* controller.abort()
|
|
||||||
*/
|
|
||||||
export function fetchEmergencyForceDetail(rid, config = {}) {
|
|
||||||
if (!rid) {
|
|
||||||
return Promise.reject(new Error('rid 参数不能为空'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return request({
|
|
||||||
url: '/snow-ops-platform/xqyjllb/getById',
|
|
||||||
method: 'GET',
|
|
||||||
params: { rid },
|
|
||||||
...config
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
@ -2,52 +2,25 @@
|
|||||||
<div class="cockpit-layout">
|
<div class="cockpit-layout">
|
||||||
<!-- <PageHeader /> -->
|
<!-- <PageHeader /> -->
|
||||||
<div class="cockpit-main">
|
<div class="cockpit-main">
|
||||||
<!-- 地图底层 -->
|
<div class="left-panel">
|
||||||
<div class="map-layer">
|
|
||||||
<MapCenter />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 地图遮罩层 -->
|
|
||||||
<div class="map-mask" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<!-- 浮动面板层 -->
|
|
||||||
<div class="panels-layer">
|
|
||||||
<div class="panel-column left-panel">
|
|
||||||
<WeatherWarning />
|
<WeatherWarning />
|
||||||
<EmergencyResources />
|
<EmergencyResources />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="center-spacer" aria-hidden="true"></div>
|
<div class="center-panel">
|
||||||
|
<MapCenter />
|
||||||
|
<LegendToolbar style="position: absolute; bottom: 10px; right: 50%; transform: translateX(50%);"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel-column right-panel">
|
<div class="right-panel">
|
||||||
<BlockEvent />
|
<BlockEvent />
|
||||||
<YearStatistics />
|
<YearStatistics />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图例工具栏 -->
|
|
||||||
<LegendToolbar class="legend-toolbar" @marker-toggle="handleLegendMarkerToggle" />
|
|
||||||
|
|
||||||
<!-- 应急力量详情提示框 -->
|
|
||||||
<EmergencyForceTooltip
|
|
||||||
:visible="emergencyForceInteraction.tooltipVisible.value"
|
|
||||||
:position="emergencyForceInteraction.tooltipPosition.value"
|
|
||||||
:data="emergencyForceInteraction.tooltipData.value"
|
|
||||||
:loading="emergencyForceInteraction.loading.value"
|
|
||||||
:error="emergencyForceInteraction.error.value"
|
|
||||||
@close="emergencyForceInteraction.hideTooltip"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onBeforeUnmount } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import useMapStore from '@/map/stores/mapStore'
|
|
||||||
import { fetchEmergencyForceList } from '@/views/cockpit/api/emergencyForce'
|
|
||||||
import { useEmergencyForceInteraction } from '../composables/useEmergencyForceInteraction'
|
|
||||||
|
|
||||||
import PageHeader from './PageHeader.vue'
|
import PageHeader from './PageHeader.vue'
|
||||||
import WeatherWarning from './WeatherWarning.vue'
|
import WeatherWarning from './WeatherWarning.vue'
|
||||||
import EmergencyResources from './EmergencyResources.vue'
|
import EmergencyResources from './EmergencyResources.vue'
|
||||||
@ -55,437 +28,6 @@ import MapCenter from './MapCenter.vue'
|
|||||||
import BlockEvent from './BlockEvent.vue'
|
import BlockEvent from './BlockEvent.vue'
|
||||||
import YearStatistics from './YearStatistics.vue'
|
import YearStatistics from './YearStatistics.vue'
|
||||||
import LegendToolbar from './LegendToolbar.vue'
|
import LegendToolbar from './LegendToolbar.vue'
|
||||||
import EmergencyForceTooltip from './EmergencyForceTooltip.vue'
|
|
||||||
import emergencyForceMarkerIcon from '../assets/legendTool/应急力量icon定位.png'
|
|
||||||
|
|
||||||
// ==================== 常量定义 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量图层 ID
|
|
||||||
* 用于在地图上标识和管理应急力量标记点
|
|
||||||
*/
|
|
||||||
const EMERGENCY_FORCE_LAYER_ID = 'legend:emergencyForce'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量数据缓存时间(毫秒)
|
|
||||||
* 60秒内重复点击使用缓存数据,减少服务器压力
|
|
||||||
*/
|
|
||||||
const EMERGENCY_FORCE_CACHE_TTL = 60 * 1000
|
|
||||||
|
|
||||||
// ==================== 状态管理 ====================
|
|
||||||
|
|
||||||
const mapStore = useMapStore()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量地图交互
|
|
||||||
* 处理点击事件和详情显示
|
|
||||||
*/
|
|
||||||
const emergencyForceInteraction = useEmergencyForceInteraction(mapStore, {
|
|
||||||
flyToPoint: false, // 默认不飞行,可配置
|
|
||||||
flyDuration: 1.5,
|
|
||||||
flyDistance: 500
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量数据加载状态
|
|
||||||
*/
|
|
||||||
const emergencyForceLoading = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量图层激活状态
|
|
||||||
*/
|
|
||||||
const emergencyForceActive = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量数据缓存
|
|
||||||
* @type {Ref<{data: Array|null, expiresAt: number}>}
|
|
||||||
*/
|
|
||||||
const emergencyForceCache = ref({
|
|
||||||
data: null,
|
|
||||||
expiresAt: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求取消控制器
|
|
||||||
* 用于在组件卸载或用户取消操作时中止正在进行的请求
|
|
||||||
*/
|
|
||||||
const emergencyForceAbortController = ref(null)
|
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 安全地将值转换为数字
|
|
||||||
* @param {*} value - 待转换的值
|
|
||||||
* @returns {number|null} 有效数字或 null
|
|
||||||
*/
|
|
||||||
const toNumber = (value) => {
|
|
||||||
const num = Number(value)
|
|
||||||
return Number.isFinite(num) ? num : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 坐标系转换(预留接口)
|
|
||||||
* 如果后端返回的坐标不是 WGS84,可以在此进行转换
|
|
||||||
* 常见坐标系:GCJ-02(火星坐标)、BD-09(百度坐标)
|
|
||||||
*
|
|
||||||
* @param {number} lon - 经度
|
|
||||||
* @param {number} lat - 纬度
|
|
||||||
* @returns {{lon: number, lat: number}} 转换后的坐标
|
|
||||||
*/
|
|
||||||
const transformToWGS84 = (lon, lat) => {
|
|
||||||
// TODO: 如果后端坐标为 GCJ-02 或 BD-09,需要在此进行坐标转换
|
|
||||||
// 可以使用 coordtransform 等库进行转换
|
|
||||||
return { lon, lat }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建请求取消控制器
|
|
||||||
* @returns {AbortController|null}
|
|
||||||
*/
|
|
||||||
const createAbortController = () => {
|
|
||||||
if (typeof AbortController === 'undefined') {
|
|
||||||
console.warn('当前环境不支持 AbortController')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return new AbortController()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消正在进行的应急力量数据请求
|
|
||||||
*/
|
|
||||||
const cancelEmergencyForceRequest = () => {
|
|
||||||
if (emergencyForceAbortController.value) {
|
|
||||||
try {
|
|
||||||
emergencyForceAbortController.value.abort()
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('取消请求失败', error)
|
|
||||||
} finally {
|
|
||||||
emergencyForceAbortController.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取地图实体服务
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {boolean} [options.silent=false] - 是否静默模式(不显示错误提示)
|
|
||||||
* @returns {Object|null} 实体服务对象或 null
|
|
||||||
*/
|
|
||||||
const getEntityService = (options = {}) => {
|
|
||||||
const silent = options.silent ?? false
|
|
||||||
|
|
||||||
if (!mapStore.isReady()) {
|
|
||||||
if (!silent) {
|
|
||||||
ElMessage.warning('地图尚未就绪,请稍后再试')
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return mapStore.services().entity
|
|
||||||
} catch (error) {
|
|
||||||
if (!silent) {
|
|
||||||
ElMessage.error('地图服务暂不可用')
|
|
||||||
}
|
|
||||||
console.error('获取地图实体服务失败', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除地图上的应急力量标记点
|
|
||||||
*/
|
|
||||||
const clearEmergencyForceMarkers = () => {
|
|
||||||
const entityService = getEntityService({ silent: true })
|
|
||||||
if (!entityService) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
entityService.clearLayerEntities(EMERGENCY_FORCE_LAYER_ID)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清除应急力量标记失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 数据处理 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析应急力量列表响应数据
|
|
||||||
* 兼容多种响应格式
|
|
||||||
*
|
|
||||||
* @param {*} response - API 响应数据
|
|
||||||
* @returns {Array} 应急力量点位数组
|
|
||||||
* @throws {Error} 当响应为空、包含错误码或格式无效时抛出异常
|
|
||||||
*/
|
|
||||||
const resolveEmergencyForceList = (response) => {
|
|
||||||
// 处理空响应 - 抛出错误而不是静默返回空数组
|
|
||||||
if (!response) {
|
|
||||||
throw new Error('应急力量数据请求失败,未收到有效响应')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接返回数组
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理标准响应格式 { code, data, message }
|
|
||||||
if (response.code) {
|
|
||||||
// 检查错误码(假设 '00000' 表示成功)
|
|
||||||
if (response.code !== '00000') {
|
|
||||||
throw new Error(response.message || '应急力量数据获取失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 data 字段
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无法识别的格式 - 抛出错误
|
|
||||||
console.error('无法识别的应急力量数据格式', response)
|
|
||||||
throw new Error('应急力量数据格式错误')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标准化应急力量点位数据
|
|
||||||
* 提取并验证经纬度,过滤无效数据
|
|
||||||
*
|
|
||||||
* @param {Array} records - 原始数据记录
|
|
||||||
* @returns {Array<{id: string, lon: number, lat: number, meta: Object}>} 标准化后的点位数据
|
|
||||||
*/
|
|
||||||
const normalizeEmergencyForcePoints = (records) => {
|
|
||||||
if (!Array.isArray(records)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return records
|
|
||||||
.map((item, index) => {
|
|
||||||
// 尝试多种可能的字段名提取经度
|
|
||||||
const lon = toNumber(
|
|
||||||
item?.jd ?? item?.lon ?? item?.lng ?? item?.longitude
|
|
||||||
)
|
|
||||||
|
|
||||||
// 尝试多种可能的字段名提取纬度
|
|
||||||
const lat = toNumber(
|
|
||||||
item?.wd ?? item?.lat ?? item?.latitude
|
|
||||||
)
|
|
||||||
|
|
||||||
// 过滤无效坐标
|
|
||||||
if (lon === null || lat === null) {
|
|
||||||
console.warn('应急力量点位缺少有效坐标', item)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 坐标系转换
|
|
||||||
const coords = transformToWGS84(lon, lat)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item?.id ? `emergencyForce-${item.id}` : `emergencyForce-${index}`,
|
|
||||||
lon: coords.lon,
|
|
||||||
lat: coords.lat,
|
|
||||||
meta: { ...item } // 保留原始数据用于后续使用
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) // 过滤掉 null 值
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载应急力量点位数据
|
|
||||||
* 优先使用缓存,缓存过期则重新请求
|
|
||||||
*
|
|
||||||
* @returns {Promise<{data: Array, fromCache: boolean}>} 标准化后的点位数据和缓存标识
|
|
||||||
* @throws {Error} 请求失败时抛出异常
|
|
||||||
*/
|
|
||||||
const loadEmergencyForcePoints = async () => {
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
// 检查缓存是否有效
|
|
||||||
if (
|
|
||||||
emergencyForceCache.value.data &&
|
|
||||||
emergencyForceCache.value.expiresAt > now
|
|
||||||
) {
|
|
||||||
console.log('使用缓存的应急力量数据')
|
|
||||||
return {
|
|
||||||
data: emergencyForceCache.value.data,
|
|
||||||
fromCache: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建取消控制器
|
|
||||||
const controller = createAbortController()
|
|
||||||
if (controller) {
|
|
||||||
emergencyForceAbortController.value = controller
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 发起 API 请求
|
|
||||||
const response = await fetchEmergencyForceList(
|
|
||||||
controller ? { signal: controller.signal } : {}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 解析和标准化数据
|
|
||||||
const list = resolveEmergencyForceList(response)
|
|
||||||
const points = normalizeEmergencyForcePoints(list)
|
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
emergencyForceCache.value = {
|
|
||||||
data: points,
|
|
||||||
expiresAt: now + EMERGENCY_FORCE_CACHE_TTL
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: points,
|
|
||||||
fromCache: false
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// 清理取消控制器
|
|
||||||
emergencyForceAbortController.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在地图上渲染应急力量点位
|
|
||||||
*
|
|
||||||
* @param {Object} entityService - 地图实体服务
|
|
||||||
* @param {Array} points - 点位数据
|
|
||||||
* @param {string} [markerIcon] - 标记图标 URL
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const renderEmergencyForcePoints = async (entityService, points, markerIcon) => {
|
|
||||||
if (!entityService) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除旧标记
|
|
||||||
entityService.clearLayerEntities(EMERGENCY_FORCE_LAYER_ID)
|
|
||||||
|
|
||||||
if (!points.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = markerIcon || emergencyForceMarkerIcon
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 批量添加标记点
|
|
||||||
await Promise.all(
|
|
||||||
points.map((point) =>
|
|
||||||
entityService.addBillboard({
|
|
||||||
id: point.id,
|
|
||||||
layerId: EMERGENCY_FORCE_LAYER_ID,
|
|
||||||
position: [point.lon, point.lat, 0],
|
|
||||||
image: icon,
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
clampToGround: true,
|
|
||||||
properties: point.meta
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(`成功渲染 ${points.length} 个应急力量点位`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('渲染应急力量点位失败', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 事件处理 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理图例工具栏的标记切换事件
|
|
||||||
*
|
|
||||||
* @param {Object} payload - 事件载荷
|
|
||||||
* @param {string} payload.key - 图例项 key
|
|
||||||
* @param {boolean} payload.active - 是否激活
|
|
||||||
* @param {string} [payload.markerIcon] - 标记图标
|
|
||||||
*/
|
|
||||||
const handleLegendMarkerToggle = async ({ key, active, markerIcon }) => {
|
|
||||||
// 只处理应急力量图例项
|
|
||||||
if (key !== 'emergencyForce') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emergencyForceActive.value = active
|
|
||||||
|
|
||||||
// 关闭图层
|
|
||||||
if (!active) {
|
|
||||||
cancelEmergencyForceRequest()
|
|
||||||
emergencyForceLoading.value = false
|
|
||||||
clearEmergencyForceMarkers()
|
|
||||||
|
|
||||||
// 禁用点击交互
|
|
||||||
emergencyForceInteraction.enabled.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防止重复加载
|
|
||||||
if (emergencyForceLoading.value) {
|
|
||||||
console.warn('应急力量数据正在加载中,请勿重复操作')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取地图服务
|
|
||||||
const entityService = getEntityService()
|
|
||||||
if (!entityService) {
|
|
||||||
emergencyForceActive.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emergencyForceLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 加载数据
|
|
||||||
const result = await loadEmergencyForcePoints()
|
|
||||||
const { data: points, fromCache } = result
|
|
||||||
|
|
||||||
// 检查用户是否在加载过程中关闭了图层
|
|
||||||
if (!emergencyForceActive.value) {
|
|
||||||
console.log('用户已取消应急力量图层显示')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染点位
|
|
||||||
await renderEmergencyForcePoints(entityService, points, markerIcon)
|
|
||||||
|
|
||||||
// 启用点击交互
|
|
||||||
emergencyForceInteraction.enabled.value = true
|
|
||||||
|
|
||||||
// 提示用户(仅在首次加载且有数据时显示成功提示)
|
|
||||||
if (!points.length) {
|
|
||||||
ElMessage.warning('未获取到应急力量点位数据')
|
|
||||||
} else if (!fromCache) {
|
|
||||||
// 只在非缓存数据时显示成功提示
|
|
||||||
ElMessage.success(`已加载 ${points.length} 个应急力量点位`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 处理请求取消
|
|
||||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
|
||||||
console.log('应急力量数据请求已取消')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理其他错误
|
|
||||||
console.error('加载应急力量点位失败', error)
|
|
||||||
ElMessage.error(error?.message || '应急力量数据加载失败,请稍后重试')
|
|
||||||
clearEmergencyForceMarkers()
|
|
||||||
|
|
||||||
// 重置图层激活状态,避免 UI 不一致
|
|
||||||
emergencyForceActive.value = false
|
|
||||||
} finally {
|
|
||||||
emergencyForceLoading.value = false
|
|
||||||
cancelEmergencyForceRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 生命周期 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组件卸载前清理资源
|
|
||||||
*/
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
emergencyForceActive.value = false
|
|
||||||
cancelEmergencyForceRequest()
|
|
||||||
clearEmergencyForceMarkers()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@ -558,65 +100,29 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cockpit-main {
|
.cockpit-main {
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0; /* 允许网格在 flex 上下文中收缩 */
|
min-height: 0; /* 允许网格在 flex 上下文中收缩 */
|
||||||
overflow: hidden; /* 防止内容溢出 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 地图底层 - 填满整个容器 */
|
|
||||||
.map-layer {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
/* 不设置 pointer-events,让地图保持可交互 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 地图遮罩层 - 覆盖在地图之上 */
|
|
||||||
.map-mask {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none; /* 不阻挡交互 */
|
|
||||||
background: url(../assets/img/遮罩层.png) no-repeat center/cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 浮动面板层 - grid 与 pointer-events 结合保证中间透明 */
|
|
||||||
.panels-layer {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width);
|
grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width);
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr; /* 强制行占据可用高度 */
|
||||||
gap: var(--cockpit-gap);
|
gap: var(--cockpit-gap);
|
||||||
height: 100%;
|
padding: var(--cockpit-padding-top) var(--cockpit-padding-x) var(--cockpit-padding-bottom);
|
||||||
box-sizing: border-box;
|
|
||||||
pointer-events: none; /* 容器不拦截事件,让中间区域透明 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 中间占位区域 - 透明且不可交互,点击穿透到地图 */
|
.left-panel,
|
||||||
.center-spacer {
|
.right-panel {
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左右面板列 - 浮动卡片样式 */
|
|
||||||
.panel-column {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--cockpit-gap);
|
gap: var(--cockpit-gap);
|
||||||
min-width: 0; /* 防止在窄容器中溢出 */
|
min-width: 0; /* 防止在窄容器中溢出 */
|
||||||
min-height: 0; /* 允许 flex 子元素收缩并启用滚动 */
|
min-height: 0; /* 允许 flex 子元素收缩并启用滚动 */
|
||||||
padding: 1rem;
|
|
||||||
pointer-events: auto; /* 恢复面板的交互能力 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图例工具栏 - 居中显示在底部 */
|
.center-panel {
|
||||||
.legend-toolbar {
|
display: flex;
|
||||||
position: absolute;
|
align-items: center;
|
||||||
bottom: var(--cockpit-gap);
|
justify-content: center;
|
||||||
left: 50%;
|
position: relative;
|
||||||
transform: translateX(-50%);
|
min-width: 0; /* 确保地图可以在需要时收缩 */
|
||||||
z-index: 3;
|
|
||||||
pointer-events: auto; /* 确保图例可交互 */
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,296 +0,0 @@
|
|||||||
<template>
|
|
||||||
<transition name="fade">
|
|
||||||
<div
|
|
||||||
v-if="visible && position"
|
|
||||||
class="emergency-force-tooltip"
|
|
||||||
:style="{
|
|
||||||
left: `${position.x}px`,
|
|
||||||
top: `${position.y}px`
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- 关闭按钮 -->
|
|
||||||
<button
|
|
||||||
class="close-button"
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭"
|
|
||||||
@click="handleClose"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
|
||||||
<div class="tooltip-content">
|
|
||||||
<div v-if="data.qxmc" class="info-item">
|
|
||||||
<span class="label">区县:</span>
|
|
||||||
<span class="value">{{ data.qxmc }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="data.yjllpz" class="info-item">
|
|
||||||
<span class="label">应急力量配置:</span>
|
|
||||||
<span class="value">{{ data.yjllpz }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="data.wzQtwz" class="info-item">
|
|
||||||
<span class="label">物资:</span>
|
|
||||||
<span class="value">{{ data.wzQtwz }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 如果没有数据 -->
|
|
||||||
<div v-if="!hasData" class="no-data">
|
|
||||||
暂无详细信息
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<div v-if="loading" class="loading-overlay">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<span>加载中...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 错误状态 -->
|
|
||||||
<div v-if="error" class="error-message">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量详情提示框组件
|
|
||||||
* 使用 HTML Overlay 方式显示在地图标记点上方
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==================== Props ====================
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/**
|
|
||||||
* 是否显示提示框
|
|
||||||
*/
|
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提示框位置(屏幕坐标)
|
|
||||||
* @type {{ x: number, y: number }}
|
|
||||||
*/
|
|
||||||
position: {
|
|
||||||
type: Object,
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 详情数据
|
|
||||||
* @type {{ qxmc?: string, yjllpz?: string, wzQtwz?: string }}
|
|
||||||
*/
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({})
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载状态
|
|
||||||
*/
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 错误信息
|
|
||||||
*/
|
|
||||||
error: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Emits ====================
|
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
|
||||||
|
|
||||||
// ==================== Computed ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否有有效数据
|
|
||||||
*/
|
|
||||||
const hasData = computed(() => {
|
|
||||||
return !!(props.data.qxmc || props.data.yjllpz || props.data.wzQtwz)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Methods ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理关闭按钮点击
|
|
||||||
*/
|
|
||||||
const handleClose = () => {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
@use '@/styles/mixins.scss' as *;
|
|
||||||
|
|
||||||
.emergency-force-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 9999;
|
|
||||||
pointer-events: auto;
|
|
||||||
|
|
||||||
// 居中并置于标记点上方
|
|
||||||
transform: translate(-50%, calc(-100% - 20px));
|
|
||||||
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 300px;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
// 与左右面板一致的样式
|
|
||||||
background: rgba(9, 22, 45, 0.95);
|
|
||||||
border: 1px solid rgba(71, 186, 255, 0.4);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
|
||||||
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
|
|
||||||
// 小箭头(指向标记点)
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -10px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 10px solid transparent;
|
|
||||||
border-right: 10px solid transparent;
|
|
||||||
border-top: 10px solid rgba(71, 186, 255, 0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭按钮
|
|
||||||
.close-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: rgba(71, 186, 255, 0.6);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内容区域
|
|
||||||
.tooltip-content {
|
|
||||||
padding-right: 1.5rem; // 为关闭按钮留出空间
|
|
||||||
}
|
|
||||||
|
|
||||||
// 信息项
|
|
||||||
.info-item {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无数据提示
|
|
||||||
.no-data {
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
.loading-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
background: rgba(9, 22, 45, 0.95);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载动画
|
|
||||||
.loading-spinner {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: 3px solid rgba(71, 186, 255, 0.2);
|
|
||||||
border-top-color: rgba(71, 186, 255, 1);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误信息
|
|
||||||
.error-message {
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: rgba(255, 107, 107, 0.1);
|
|
||||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 淡入淡出动画
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, calc(-100% - 10px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, calc(-100% - 30px));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -5,7 +5,7 @@
|
|||||||
<MapControls />
|
<MapControls />
|
||||||
</div>
|
</div>
|
||||||
<!-- 顶部功能按钮 -->
|
<!-- 顶部功能按钮 -->
|
||||||
<!-- <div class="top-buttons">
|
<div class="top-buttons">
|
||||||
<button
|
<button
|
||||||
v-for="button in topButtons"
|
v-for="button in topButtons"
|
||||||
:key="button.id"
|
:key="button.id"
|
||||||
@ -15,7 +15,7 @@
|
|||||||
<img :src="button.icon" :alt="button.label" class="button-icon" />
|
<img :src="button.icon" :alt="button.label" class="button-icon" />
|
||||||
<span>{{ button.label }}</span>
|
<span>{{ button.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div> -->
|
</div>
|
||||||
|
|
||||||
<!-- 地图标记点 (这里应该集成实际地图,现在用占位符) -->
|
<!-- 地图标记点 (这里应该集成实际地图,现在用占位符) -->
|
||||||
<!-- <div class="map-markers">
|
<!-- <div class="map-markers">
|
||||||
|
|||||||
@ -150,6 +150,7 @@ const districts = ref([
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: vw(20);
|
gap: vw(20);
|
||||||
|
margin: 0 vw(30);
|
||||||
padding: 0 vw(27);
|
padding: 0 vw(27);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,468 +0,0 @@
|
|||||||
/**
|
|
||||||
* 应急力量地图交互 Composable
|
|
||||||
* 处理应急力量标记点的点击事件,使用 HTML Overlay 显示详情信息
|
|
||||||
*
|
|
||||||
* @module composables/useEmergencyForceInteraction
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
|
||||||
import * as Cesium from 'cesium'
|
|
||||||
import { fetchEmergencyForceDetail } from '../api/emergencyForce'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应急力量地图交互 Hook
|
|
||||||
*
|
|
||||||
* @param {Object} mapStore - 地图 Store 实例
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {boolean} [options.flyToPoint=false] - 点击时是否飞行到点位
|
|
||||||
* @param {number} [options.flyDuration=1.5] - 飞行动画时长(秒)
|
|
||||||
* @param {number} [options.flyDistance=500] - 飞行后的相机距离(米)
|
|
||||||
* @returns {Object} 交互状态和方法
|
|
||||||
*/
|
|
||||||
export function useEmergencyForceInteraction(mapStore, options = {}) {
|
|
||||||
// ==================== 配置选项 ====================
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
flyToPoint: options.flyToPoint ?? false,
|
|
||||||
flyDuration: options.flyDuration ?? 1.5,
|
|
||||||
flyDistance: options.flyDistance ?? 500
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 状态管理 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否启用交互
|
|
||||||
*/
|
|
||||||
const enabled = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前选中的实体
|
|
||||||
*/
|
|
||||||
const selectedEntity = ref(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tooltip 是否可见
|
|
||||||
*/
|
|
||||||
const tooltipVisible = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tooltip 屏幕位置
|
|
||||||
* @type {Ref<{x: number, y: number}|null>}
|
|
||||||
*/
|
|
||||||
const tooltipPosition = ref(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tooltip 数据
|
|
||||||
* @type {Ref<{qxmc?: string, yjllpz?: string, wzQtwz?: string}>}
|
|
||||||
*/
|
|
||||||
const tooltipData = ref({})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载状态
|
|
||||||
*/
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 错误信息
|
|
||||||
*/
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求取消控制器
|
|
||||||
*/
|
|
||||||
const abortController = ref(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cesium 事件处理器
|
|
||||||
*/
|
|
||||||
let clickHandler = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* postRender 事件监听器
|
|
||||||
*/
|
|
||||||
let postRenderListener = null
|
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Cesium Viewer 实例
|
|
||||||
* @returns {Cesium.Viewer|null}
|
|
||||||
*/
|
|
||||||
const getViewer = () => {
|
|
||||||
if (!mapStore.isReady()) {
|
|
||||||
console.warn('地图尚未就绪')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return mapStore.getViewer()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消正在进行的请求
|
|
||||||
*/
|
|
||||||
const cancelRequest = () => {
|
|
||||||
if (abortController.value) {
|
|
||||||
try {
|
|
||||||
abortController.value.abort()
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('取消请求失败', error)
|
|
||||||
} finally {
|
|
||||||
abortController.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 Tooltip 位置
|
|
||||||
* 将 3D 世界坐标转换为屏幕坐标
|
|
||||||
* 特别处理 clampToGround 实体,确保使用地形高度
|
|
||||||
*/
|
|
||||||
const updateTooltipPosition = () => {
|
|
||||||
if (!selectedEntity.value || !tooltipVisible.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewer = getViewer()
|
|
||||||
if (!viewer) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const position = selectedEntity.value.position?.getValue(Cesium.JulianDate.now())
|
|
||||||
if (!position) return
|
|
||||||
|
|
||||||
// 处理 clampToGround 实体
|
|
||||||
// 对于使用 CLAMP_TO_GROUND 的 billboard,需要获取实际的地形高度
|
|
||||||
let clampedPosition = position
|
|
||||||
|
|
||||||
// 转换为地理坐标
|
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(position)
|
|
||||||
|
|
||||||
// 尝试获取地形高度
|
|
||||||
if (viewer.scene.globe) {
|
|
||||||
const height = viewer.scene.globe.getHeight(cartographic)
|
|
||||||
|
|
||||||
// 如果成功获取地形高度,使用地形高度重建位置
|
|
||||||
if (Cesium.defined(height)) {
|
|
||||||
cartographic.height = height
|
|
||||||
clampedPosition = Cesium.Cartographic.toCartesian(cartographic)
|
|
||||||
} else {
|
|
||||||
// 备用方案:尝试使用 clampToHeight
|
|
||||||
const clampedCartesian = viewer.scene.clampToHeight(position)
|
|
||||||
if (clampedCartesian) {
|
|
||||||
clampedPosition = clampedCartesian
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用修正后的位置进行屏幕坐标转换
|
|
||||||
const screenPosition = viewer.scene.cartesianToCanvasCoordinates(clampedPosition)
|
|
||||||
if (screenPosition) {
|
|
||||||
tooltipPosition.value = {
|
|
||||||
x: screenPosition.x,
|
|
||||||
y: screenPosition.y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新 Tooltip 位置失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册 postRender 监听器
|
|
||||||
* 实时更新 Tooltip 位置(相机移动时)
|
|
||||||
*/
|
|
||||||
const registerPostRenderListener = () => {
|
|
||||||
const viewer = getViewer()
|
|
||||||
if (!viewer || postRenderListener) return
|
|
||||||
|
|
||||||
postRenderListener = () => {
|
|
||||||
updateTooltipPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
viewer.scene.postRender.addEventListener(postRenderListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注销 postRender 监听器
|
|
||||||
*/
|
|
||||||
const unregisterPostRenderListener = () => {
|
|
||||||
const viewer = getViewer()
|
|
||||||
if (!viewer || !postRenderListener) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
viewer.scene.postRender.removeEventListener(postRenderListener)
|
|
||||||
postRenderListener = null
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注销 postRender 监听器失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 相机飞行到指定实体
|
|
||||||
* @param {Cesium.Entity} entity - 目标实体
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const flyToEntity = async (entity) => {
|
|
||||||
const viewer = getViewer()
|
|
||||||
if (!viewer || !config.flyToPoint) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const position = entity.position?.getValue(Cesium.JulianDate.now())
|
|
||||||
if (!position) return
|
|
||||||
|
|
||||||
await viewer.camera.flyTo({
|
|
||||||
destination: position,
|
|
||||||
duration: config.flyDuration,
|
|
||||||
offset: new Cesium.HeadingPitchRange(
|
|
||||||
0,
|
|
||||||
Cesium.Math.toRadians(-45),
|
|
||||||
config.flyDistance
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('相机飞行失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示 Tooltip
|
|
||||||
* @param {Cesium.Entity} entity - 目标实体
|
|
||||||
* @param {Object} data - 详情数据
|
|
||||||
*/
|
|
||||||
const showTooltip = (entity, data) => {
|
|
||||||
selectedEntity.value = entity
|
|
||||||
tooltipData.value = data
|
|
||||||
tooltipVisible.value = true
|
|
||||||
updateTooltipPosition()
|
|
||||||
registerPostRenderListener()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏 Tooltip
|
|
||||||
*/
|
|
||||||
const hideTooltip = () => {
|
|
||||||
tooltipVisible.value = false
|
|
||||||
tooltipPosition.value = null
|
|
||||||
tooltipData.value = {}
|
|
||||||
selectedEntity.value = null
|
|
||||||
error.value = ''
|
|
||||||
unregisterPostRenderListener()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 核心逻辑 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理应急力量实体点击
|
|
||||||
* @param {Cesium.Entity} entity - 被点击的实体
|
|
||||||
*/
|
|
||||||
const handleEmergencyForceClick = async (entity) => {
|
|
||||||
// 取消之前的请求
|
|
||||||
cancelRequest()
|
|
||||||
|
|
||||||
// 提取 rid
|
|
||||||
const rid = entity.properties?.rid?.getValue()
|
|
||||||
|
|
||||||
if (!rid) {
|
|
||||||
console.warn('应急力量实体缺少 rid 属性')
|
|
||||||
error.value = '缺少标识信息'
|
|
||||||
showTooltip(entity, {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示加载状态
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
showTooltip(entity, {})
|
|
||||||
|
|
||||||
// 可选:飞行到点位
|
|
||||||
await flyToEntity(entity)
|
|
||||||
|
|
||||||
// 创建取消控制器
|
|
||||||
const controller = new AbortController()
|
|
||||||
abortController.value = controller
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 请求详情数据
|
|
||||||
const response = await fetchEmergencyForceDetail(rid, {
|
|
||||||
signal: controller.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查是否仍然选中该实体
|
|
||||||
if (selectedEntity.value !== entity) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析响应数据
|
|
||||||
let detailData = response
|
|
||||||
if (response && typeof response === 'object') {
|
|
||||||
if (response.data) {
|
|
||||||
detailData = response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 Tooltip 数据
|
|
||||||
tooltipData.value = {
|
|
||||||
qxmc: detailData.qxmc || '',
|
|
||||||
yjllpz: detailData.yjllpz || '',
|
|
||||||
wzQtwz: detailData.wzQtwz || ''
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// 处理请求取消
|
|
||||||
if (err.name === 'AbortError' || err.name === 'CanceledError') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理其他错误
|
|
||||||
console.error('加载应急力量详情失败:', err)
|
|
||||||
|
|
||||||
let errorMessage = '加载失败'
|
|
||||||
if (err.response) {
|
|
||||||
errorMessage += `: ${err.response.status}`
|
|
||||||
if (err.response.data?.message) {
|
|
||||||
errorMessage += ` - ${err.response.data.message}`
|
|
||||||
}
|
|
||||||
} else if (err.message) {
|
|
||||||
errorMessage += `: ${err.message}`
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = errorMessage
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
abortController.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理地图点击事件
|
|
||||||
* @param {Object} click - 点击事件对象
|
|
||||||
*/
|
|
||||||
const handleMapClick = (click) => {
|
|
||||||
if (!enabled.value) return
|
|
||||||
|
|
||||||
const viewer = getViewer()
|
|
||||||
if (!viewer) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 拾取点击的对象
|
|
||||||
const pickedObject = viewer.scene.pick(click.position)
|
|
||||||
|
|
||||||
// 检查是否点击了应急力量实体
|
|
||||||
if (
|
|
||||||
pickedObject &&
|
|
||||||
pickedObject.id &&
|
|
||||||
pickedObject.id.id &&
|
|
||||||
typeof pickedObject.id.id === 'string' &&
|
|
||||||
pickedObject.id.id.startsWith('emergencyForce-')
|
|
||||||
) {
|
|
||||||
// 点击了应急力量标记
|
|
||||||
handleEmergencyForceClick(pickedObject.id)
|
|
||||||
} else {
|
|
||||||
// 点击了其他地方,隐藏 Tooltip
|
|
||||||
if (tooltipVisible.value) {
|
|
||||||
hideTooltip()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('处理地图点击失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册点击事件监听器
|
|
||||||
*/
|
|
||||||
const registerClickHandler = () => {
|
|
||||||
const viewer = getViewer()
|
|
||||||
if (!viewer) {
|
|
||||||
console.warn('无法注册点击事件:地图未就绪')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 避免重复注册
|
|
||||||
if (clickHandler) {
|
|
||||||
console.warn('点击事件已注册')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
|
|
||||||
clickHandler.setInputAction(
|
|
||||||
handleMapClick,
|
|
||||||
Cesium.ScreenSpaceEventType.LEFT_CLICK
|
|
||||||
)
|
|
||||||
console.log('应急力量点击事件已注册')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注册点击事件失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注销点击事件监听器
|
|
||||||
*/
|
|
||||||
const unregisterClickHandler = () => {
|
|
||||||
if (clickHandler) {
|
|
||||||
try {
|
|
||||||
clickHandler.destroy()
|
|
||||||
clickHandler = null
|
|
||||||
console.log('应急力量点击事件已注销')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注销点击事件失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理所有资源
|
|
||||||
*/
|
|
||||||
const cleanup = () => {
|
|
||||||
cancelRequest()
|
|
||||||
hideTooltip()
|
|
||||||
unregisterClickHandler()
|
|
||||||
unregisterPostRenderListener()
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 生命周期 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听启用状态变化
|
|
||||||
*/
|
|
||||||
watch(enabled, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
// 启用交互
|
|
||||||
if (mapStore.isReady()) {
|
|
||||||
registerClickHandler()
|
|
||||||
} else {
|
|
||||||
// 等待地图就绪
|
|
||||||
mapStore.onReady(() => {
|
|
||||||
if (enabled.value) {
|
|
||||||
registerClickHandler()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 禁用交互
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组件卸载前清理
|
|
||||||
*/
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== 返回接口 ====================
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
enabled,
|
|
||||||
loading,
|
|
||||||
tooltipVisible,
|
|
||||||
tooltipPosition,
|
|
||||||
tooltipData,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
cleanup,
|
|
||||||
hideTooltip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user