Compare commits

...

6 Commits

Author SHA1 Message Date
Zzc
723e7ddf40 Merge branch 'dev' of http://222.212.85.86:8222/bdzl2/bxztApp into dev 2025-11-13 18:01:11 +08:00
Zzc
548226263a feat(cockpit): 添加紧急力量地图交互和工具提示
- 添加用于获取紧急力量列表和详细信息的 API 函数
- 将地图上的紧急力量标记与切换功能集成
- 实现用于在点击时显示力量详细信息的工具提示组件
- 创建可组合函数以处理地图交互,包括点击事件和位置更新
- 更新驾驶舱布局以支持覆盖图层和工具提示定位
2025-11-13 18:01:02 +08:00
Zzc
f1f9e24d0a feat(cockpit): 在MapCenter组件中禁用顶部按钮 2025-11-13 17:59:11 +08:00
Zzc
2a4e585e64 style(cockpit): 移除天气预警网格的边距 2025-11-13 17:58:12 +08:00
Zzc
12b1162917 feat(cockpit): 添加图例工具图标和遮罩层 2025-11-13 17:57:14 +08:00
Zzc
105b3296af feat(map): 添加创建 billboard 实体的方法
在实体服务中添加一个新的异步方法 `addBillboard`,支持在地图中创建并添加广告牌实体。该方法支持可配置选项,包括位置、图像、尺寸、贴合度和像素偏移量,以增强地图可视化效果。
2025-11-13 17:55:40 +08:00
11 changed files with 1428 additions and 26 deletions

View File

@ -95,6 +95,86 @@ export function createEntityService(deps) {
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) {
if (!entityId) return false
for (const id in store.layers) {

View 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
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -2,25 +2,52 @@
<div class="cockpit-layout">
<!-- <PageHeader /> -->
<div class="cockpit-main">
<div class="left-panel">
<WeatherWarning />
<EmergencyResources />
</div>
<div class="center-panel">
<!-- 地图底层 -->
<div class="map-layer">
<MapCenter />
<LegendToolbar style="position: absolute; bottom: 10px; right: 50%; transform: translateX(50%);"/>
</div>
<div class="right-panel">
<BlockEvent />
<YearStatistics />
<!-- 地图遮罩层 -->
<div class="map-mask" aria-hidden="true"></div>
<!-- 浮动面板层 -->
<div class="panels-layer">
<div class="panel-column left-panel">
<WeatherWarning />
<EmergencyResources />
</div>
<div class="center-spacer" aria-hidden="true"></div>
<div class="panel-column right-panel">
<BlockEvent />
<YearStatistics />
</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>
</template>
<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 WeatherWarning from './WeatherWarning.vue'
import EmergencyResources from './EmergencyResources.vue'
@ -28,6 +55,437 @@ import MapCenter from './MapCenter.vue'
import BlockEvent from './BlockEvent.vue'
import YearStatistics from './YearStatistics.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>
<style scoped lang="scss">
@ -100,29 +558,65 @@ import LegendToolbar from './LegendToolbar.vue'
}
.cockpit-main {
position: relative;
flex: 1;
min-height: 0; /* 允许网格在 flex 上下文中收缩 */
display: grid;
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);
overflow: hidden; /* 防止内容溢出 */
}
.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;
flex-direction: column;
gap: var(--cockpit-gap);
min-width: 0; /* 防止在窄容器中溢出 */
min-height: 0; /* 允许 flex 子元素收缩并启用滚动 */
padding: 1rem;
pointer-events: auto; /* 恢复面板的交互能力 */
}
.center-panel {
display: flex;
align-items: center;
justify-content: center;
position: relative;
min-width: 0; /* 确保地图可以在需要时收缩 */
/* 图例工具栏 - 居中显示在底部 */
.legend-toolbar {
position: absolute;
bottom: var(--cockpit-gap);
left: 50%;
transform: translateX(-50%);
z-index: 3;
pointer-events: auto; /* 确保图例可交互 */
}
</style>

View File

@ -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>

View File

@ -5,7 +5,7 @@
<MapControls />
</div>
<!-- 顶部功能按钮 -->
<div class="top-buttons">
<!-- <div class="top-buttons">
<button
v-for="button in topButtons"
:key="button.id"
@ -15,7 +15,7 @@
<img :src="button.icon" :alt="button.label" class="button-icon" />
<span>{{ button.label }}</span>
</button>
</div>
</div> -->
<!-- 地图标记点 (这里应该集成实际地图现在用占位符) -->
<!-- <div class="map-markers">

View File

@ -150,7 +150,6 @@ const districts = ref([
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: vw(20);
margin: 0 vw(30);
padding: 0 vw(27);
}

View File

@ -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
}
}