feat(cockpit): 添加紧急力量地图交互和工具提示
- 添加用于获取紧急力量列表和详细信息的 API 函数 - 将地图上的紧急力量标记与切换功能集成 - 实现用于在点击时显示力量详细信息的工具提示组件 - 创建可组合函数以处理地图交互,包括点击事件和位置更新 - 更新驾驶舱布局以支持覆盖图层和工具提示定位
This commit is contained in:
parent
f1f9e24d0a
commit
548226263a
65
packages/screen/src/views/cockpit/api/emergencyForce.js
Normal file
65
packages/screen/src/views/cockpit/api/emergencyForce.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 应急力量相关 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -2,25 +2,52 @@
|
|||||||
<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-panel">
|
<div class="center-spacer" aria-hidden="true"></div>
|
||||||
<MapCenter />
|
|
||||||
<LegendToolbar style="position: absolute; bottom: 10px; right: 50%; transform: translateX(50%);"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="right-panel">
|
<div class="panel-column 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'
|
||||||
@ -28,6 +55,437 @@ 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">
|
||||||
@ -100,29 +558,65 @@ import LegendToolbar from './LegendToolbar.vue'
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cockpit-main {
|
.cockpit-main {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0; /* 允许网格在 flex 上下文中收缩 */
|
min-height: 0; /* 允许网格在 flex 上下文中收缩 */
|
||||||
display: grid;
|
overflow: hidden; /* 防止内容溢出 */
|
||||||
grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width);
|
|
||||||
grid-auto-rows: 1fr; /* 强制行占据可用高度 */
|
|
||||||
gap: var(--cockpit-gap);
|
|
||||||
padding: var(--cockpit-padding-top) var(--cockpit-padding-x) var(--cockpit-padding-bottom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel,
|
/* 地图底层 - 填满整个容器 */
|
||||||
.right-panel {
|
.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;
|
||||||
|
grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width);
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
gap: var(--cockpit-gap);
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none; /* 容器不拦截事件,让中间区域透明 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中间占位区域 - 透明且不可交互,点击穿透到地图 */
|
||||||
|
.center-spacer {
|
||||||
|
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 {
|
/* 图例工具栏 - 居中显示在底部 */
|
||||||
display: flex;
|
.legend-toolbar {
|
||||||
align-items: center;
|
position: absolute;
|
||||||
justify-content: center;
|
bottom: var(--cockpit-gap);
|
||||||
position: relative;
|
left: 50%;
|
||||||
min-width: 0; /* 确保地图可以在需要时收缩 */
|
transform: translateX(-50%);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: auto; /* 确保图例可交互 */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,296 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,468 @@
|
|||||||
|
/**
|
||||||
|
* 应急力量地图交互 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