1229 lines
34 KiB
Vue
Raw Normal View History

<template>
<div class="situational-awareness">
<!-- 顶部导航栏 -->
<PageHeader @back="handleBack" />
<!-- 主内容区域 -->
<div class="situational-awareness__main">
<!-- 地图底层 -->
<div
class="situational-awareness__map-layer"
:class="{ 'is-compare-mode': isCompareMode }"
>
<!-- 左侧地图容器对比模式下显示灾前场景 -->
<div
id="leftCesiumContainer"
class="situational-awareness__left-map"
></div>
<!-- 中间分割线 -->
<div
v-if="isCompareMode"
class="situational-awareness__center-divider"
></div>
<!-- 右侧地图主地图 - 灾后场景 -->
<div class="situational-awareness__right-map">
2025-11-19 09:29:47 +08:00
<MapViewer @tool-change="handleMapToolChange" />
</div>
2025-11-19 17:06:05 +08:00
</div>
2025-11-19 17:06:05 +08:00
<!-- 地图遮罩层 -->
<div class="situational-awareness__map-mask" aria-hidden="true"></div>
<!-- 场景标签层 - 独立层级显示在遮罩层之上 -->
<div class="situational-awareness__scene-labels-layer">
<!-- 灾前现场实景标签 - 在中间分割线左侧 -->
<SceneLabel
v-if="isCompareMode"
text="灾前现场实景"
position="center-left"
/>
<!-- 灾后现场实景标签 - 在右侧面板左边 -->
<SceneLabel
text="灾后现场实景"
position="right-left"
/>
</div>
<!-- 浮动面板层 -->
<div class="situational-awareness__panels-layer">
2025-11-19 17:06:05 +08:00
<Transition name="panel-slide-left">
<div
v-show="!isLeftPanelCollapsed"
class="situational-awareness__panel-column situational-awareness__panel-column--left"
>
<LeftPanel
@start-dispatch="handleStartDispatch"
@view-plan="handleViewPlan"
/>
2025-11-19 17:06:05 +08:00
</div>
</Transition>
<Transition name="panel-slide-right">
<div
v-show="!isRightPanelCollapsed"
class="situational-awareness__panel-column situational-awareness__panel-column--right"
>
<RightPanel />
</div>
</Transition>
</div>
<!-- 折叠按钮层 -->
<div class="situational-awareness__collapse-buttons-layer">
<!-- 左侧折叠按钮 -->
<button
class="situational-awareness__collapse-btn situational-awareness__collapse-btn--left"
:class="{ 'is-collapsed': isLeftPanelCollapsed }"
@click="toggleLeftPanel"
:aria-label="isLeftPanelCollapsed ? '展开左侧面板' : '收起左侧面板'"
>
2025-11-19 17:06:05 +08:00
<img
:src="isLeftPanelCollapsed ? collapseRightArrow : collapseLeftArrow"
alt=""
class="collapse-arrow"
/>
</button>
<!-- 右侧折叠按钮 -->
<button
class="situational-awareness__collapse-btn situational-awareness__collapse-btn--right"
:class="{ 'is-collapsed': isRightPanelCollapsed }"
@click="toggleRightPanel"
:aria-label="isRightPanelCollapsed ? '展开右侧面板' : '收起右侧面板'"
>
2025-11-19 17:06:05 +08:00
<img
:src="isRightPanelCollapsed ? collapseLeftArrow : collapseRightArrow"
alt=""
class="collapse-arrow"
/>
</button>
</div>
<!-- 地图控件层 - 高于遮罩和面板 -->
<div class="situational-awareness__controls-layer">
<div id="sa-controls" class="situational-awareness__controls"></div>
</div>
<!-- 地图 Tooltip - 用于显示地图标记点的轻量级信息提示框 -->
<div class="situational-awareness__tooltip-layer">
<MapTooltip
2025-11-24 16:50:43 +08:00
:visible="mapTooltip.visible"
:x="mapTooltip.x"
:y="mapTooltip.y"
:title="mapTooltip.title"
:icon="mapTooltip.icon"
:z-index="mapTooltip.zIndex"
:show-video-icon="mapTooltip.data && mapTooltip.data.supportVideo"
:video-icon-src="mediaIcon"
@video-icon-click="handleVideoIconClick"
@close="handleMapTooltipClose"
>
<!-- Tooltip 内容插槽 - 根据实际业务数据渲染 -->
<template v-if="mapTooltip.data && mapTooltip.data.fields">
<div
v-for="(field, index) in mapTooltip.data.fields"
:key="index"
class="tooltip-field-item"
>
<span class="tooltip-field-label">{{ field.label }}</span>
<span class="tooltip-field-value">{{ field.value }}</span>
</div>
</template>
<!-- 操作按钮插槽 -->
<template v-if="mapTooltip.data && mapTooltip.data.actions" #actions>
<button
v-for="(action, index) in mapTooltip.data.actions"
:key="index"
class="tooltip-action-btn"
@click="handleTooltipAction(action)"
>
{{ action.label }}
</button>
</template>
</MapTooltip>
</div>
<!-- 加载动画层 - 一键启动后显示 -->
<div v-if="showLoading" class="situational-awareness__loading-layer">
<img
src="./assets/images/加载2.gif"
alt="加载中"
class="situational-awareness__loading-gif"
/>
<!-- <img
src="./assets/images/加载.gif"
alt="加载中"
class="situational-awareness__loading-gif"
/> -->
</div>
</div>
<!-- 弹窗组件 -->
<PersonnelDetail
:visible="showPersonnelDetail"
:personnel-data="selectedPersonnel"
@close="showPersonnelDetail = false"
@link="handlePersonnelLink"
/>
<EmergencyCenterDetail
:visible="showCenterDetail"
:center-data="selectedCenter"
@close="showCenterDetail = false"
/>
<!-- 视频监控弹窗 -->
<VideoModal
v-if="showVideoModal"
:visible="showVideoModal"
:monitor="selectedVideoMonitor"
@close="handleVideoModalClose"
/>
<!-- 智能应急方案弹窗 -->
<StretchableModal
2025-11-24 16:50:43 +08:00
:visible="showStretchableModal"
title="公路灾害现场处置推荐方案"
:close-on-click-modal="true"
:close-on-press-escape="true"
:show-close="true"
2025-11-25 10:05:30 +08:00
@close="showStretchableModal = false"
width="clamp(100px, 50vw, 1400px)"
>
<!-- 使用应急方案内容组件 -->
<EmergencyPlanContent :stations="disasterData.forcePreset.value.stations" />
<!-- 底部一键启动按钮 -->
<template #footer>
<ActionButton
text="一键启动"
type="primary"
size="medium"
@click="handleModalStartDispatch"
/>
</template>
</StretchableModal>
</div>
</template>
<script setup>
/**
* 3D 态势感知主页面重构版
*
* 架构说明
* - 使用 Composition API Composable 模式
* - 生命周期管理useCesiumLifecycle 统一管理资源
* - 功能模块化每个功能由独立的 composable 管理
* - 数据层分离硬编码数据移至 constants 目录
*
* 主要职责
* 1. 组件布局和模板渲染
* 2. 子组件编排和事件转发
* 3. 生命周期入口初始化和清理
* 4. 用户交互事件处理
*/
import { ref, provide, onMounted, onUnmounted } from 'vue'
import * as Cesium from 'cesium'
import { ElMessage } from 'element-plus'
// ========== 组件导入 ==========
import PageHeader from './components/PageHeader.vue'
import LeftPanel from './components/LeftPanel/index.vue'
import MapViewer from './components/MapViewer/index.vue'
import RightPanel from './components/RightPanel/index.vue'
import PersonnelDetail from './components/Popups/PersonnelDetail.vue'
import EmergencyCenterDetail from './components/Popups/EmergencyCenterDetail.vue'
import VideoModal from './components/RightPanel/VideoModal.vue'
import MapTooltip from './components/shared/MapTooltip.vue'
import SceneLabel from './components/SceneLabel.vue'
import StretchableModal from './components/shared/StretchableModal.vue'
import ActionButton from './components/shared/ActionButton.vue'
import EmergencyPlanContent from './components/shared/EmergencyPlanContent.vue'
// ========== Composables 导入 ==========
import { useDisasterData } from './composables/useDisasterData'
import { useDualMapCompare } from './composables/useDualMapCompare'
import { useMapMarkers } from './composables/useMapMarkers'
import { use3DTiles } from './composables/use3DTiles'
import { useEntityAnimation } from './composables/useEntityAnimation'
// 新的 composables
import { useCesiumLifecycle } from './composables/useCesiumLifecycle'
import { useMapTooltip } from './composables/useMapTooltip'
import { useMockData } from './composables/useMockData'
import { useMapClickHandler } from './composables/useMapClickHandler'
import { useRangeCircle } from './composables/useRangeCircle'
import { usePathLines } from './composables/usePathLines'
import { useEmergencyDispatch } from './composables/useEmergencyDispatch'
// ========== 工具和常量导入 ==========
import { useMapStore } from '@/map'
import { request } from '@shared/utils/request'
import {
DISASTER_CENTER,
DEFAULT_CAMERA_CARTESIAN,
DEFAULT_CAMERA_POSITION,
DEFAULT_CAMERA_VIEW,
DEFAULT_SEARCH_RADIUS,
MARKER_ICON_SIZE,
} from './constants'
// ========== 图标资源导入 ==========
import emergencyCenterIcon from './assets/images/应急中心.png'
import eventIcon from './assets/images/事件icon.png'
import soldierIcon from './assets/images/SketchPngfbec927027ff9e49207749ebaafd229429315341fda199251b6dfb1723ff17fb.png'
import deviceIcon from './assets/images/SketchPng860d54f2a31f5f441fc6a88081224f1e98534bf6d5ca1246e420983bdf690380.png'
import emergencyBaseIcon from './assets/images/应急基地.png'
import reserveCenterIcon from './assets/images/储备中心.png'
import mediaIcon from './assets/images/media.png'
import collapseLeftArrow from './assets/images/折叠面板左箭头.png'
import collapseRightArrow from './assets/images/折叠面板右箭头.png'
// ====================
// 数据层
// ====================
// 使用灾害数据 composable
const disasterData = useDisasterData()
// 地图 store
const mapStore = useMapStore()
// ====================
// 生命周期管理
// ====================
// Cesium 资源生命周期管理
const {
registerEventHandler,
registerPostRenderListener,
registerTimeout,
cleanup,
} = useCesiumLifecycle()
// ====================
// 地图功能模块
// ====================
// 双地图对比功能
const { isCompareMode, toggleCompareMode } = useDualMapCompare()
// 地图标记功能
const {
initializeMarkers,
addEmergencyResourceMarkers,
clearEmergencyResourceMarkers,
addReserveCenterMarkers,
clearReserveCenterMarkers,
} = useMapMarkers()
// 3D Tiles 加载
const { load3DTileset, waitForTilesetReady } = use3DTiles()
// 地图 Tooltip
const tooltipComposable = useMapTooltip()
const { tooltipState: mapTooltip, showTooltip, hideTooltip } = tooltipComposable
// 实体动画
const entityAnimationComposable = useEntityAnimation()
// 模拟数据服务
const mockDataService = useMockData()
// 范围圈管理
const rangeCircleComposable = useRangeCircle()
const { rangeCircleEntity, createOrUpdateRangeCircle, clearRangeCircle } =
rangeCircleComposable
// 路径线管理
const pathLinesComposable = usePathLines()
// 地图点击事件处理
const mapClickHandler = useMapClickHandler({
tooltipComposable,
icons: {
soldierIcon,
deviceIcon,
emergencyCenterIcon,
emergencyBaseIcon,
reserveCenterIcon,
},
rangeCircleEntity,
})
// 应急调度流程
const { showLoading, startDispatch } = useEmergencyDispatch({
pathLinesComposable,
entityAnimationComposable,
mapStore,
registerTimeoutFn: registerTimeout,
})
// ====================
// UI 状态
// ====================
// 面板折叠状态
const isLeftPanelCollapsed = ref(false)
const isRightPanelCollapsed = ref(false)
// 弹窗状态
const showPersonnelDetail = ref(false)
const showCenterDetail = ref(false)
const showStretchableModal = ref(false)
const showVideoModal = ref(false)
// 选中的数据
const selectedPersonnel = ref({
name: '张强',
department: '安全生产部',
distance: 0.6,
estimatedArrival: 10,
avatar: null,
})
const selectedCenter = ref({
name: '忠县应急中心',
adminLevel: '国道',
department: '交通公路部门',
distance: 0.6,
image: null,
})
const selectedVideoMonitor = ref({
id: '',
title: '',
videoSrc: '',
dateRange: '',
hasMegaphone: false,
hasAudio: true,
hasDirectionControl: false,
})
// ====================
// 事件处理函数
// ====================
2025-11-19 17:06:05 +08:00
/**
* 切换左侧面板
*/
2025-11-19 17:06:05 +08:00
const toggleLeftPanel = () => {
isLeftPanelCollapsed.value = !isLeftPanelCollapsed.value
}
2025-11-19 17:06:05 +08:00
/**
* 切换右侧面板
*/
2025-11-19 17:06:05 +08:00
const toggleRightPanel = () => {
isRightPanelCollapsed.value = !isRightPanelCollapsed.value
}
/**
* 返回驾驶舱
*/
const handleBack = () => {
console.log('返回驾驶舱')
// 实际实现:路由跳转
// router.push('/cockpit')
}
/**
* 处理人员联动
*/
const handlePersonnelLink = (personnel) => {
console.log('联动人员:', personnel)
showPersonnelDetail.value = false
}
/**
* 处理 Tooltip 操作按钮点击事件
*/
const handleTooltipAction = (action) => {
console.log('[index.vue] Tooltip 操作按钮点击:', action)
if (action.type === 'link') {
// 应急人员的"联动"操作
ElMessage.success(`已联动应急人员: ${action.data.name?.getValue() || '未知'}`)
hideTooltip()
} else if (action.type === 'connect') {
// 应急基地的"连线"操作
ElMessage.success(`已连线应急基地: ${action.data.name?.getValue() || '未知'}`)
hideTooltip()
}
}
/**
* 关闭地图 Tooltip
*/
const handleMapTooltipClose = () => {
mapTooltip.value.visible = false
}
/**
* 处理视频图标点击事件
*/
const handleVideoIconClick = () => {
const { data } = mapTooltip.value
if (data && data.supportVideo) {
selectedVideoMonitor.value = {
id: Date.now().toString(),
title: data.videoTitle || '视频监控',
videoSrc: data.videoSrc || '',
dateRange: new Date().toLocaleDateString(),
hasMegaphone: false,
hasAudio: true,
hasDirectionControl: false,
}
showVideoModal.value = true
}
}
/**
* 处理视频弹窗关闭事件
*/
const handleVideoModalClose = () => {
showVideoModal.value = false
}
/**
* 处理力量调度启动事件
*/
const handleStartDispatch = (payload) => {
startDispatch(payload)
}
/**
* 处理查看智能应急方案事件
*/
const handleViewPlan = (plan) => {
console.log('[index.vue] 查看智能应急方案:', plan)
showStretchableModal.value = true
}
/**
* 处理弹窗中的一键启动按钮点击事件
*/
const handleModalStartDispatch = () => {
console.log('[index.vue] 弹窗中点击一键启动')
showStretchableModal.value = false
handleStartDispatch({
planName: '智能应急方案',
plan: disasterData.forceDispatch.value.plan,
responseLevel: disasterData.forceDispatch.value.responseLevel,
estimatedClearTime: disasterData.forceDispatch.value.estimatedClearTime,
})
}
/**
* 处理地图工具变化事件
*/
const handleMapToolChange = async ({ tool, active }) => {
console.log(`地图工具变化: ${tool}, 激活状态: ${active}`)
if (tool === 'modelCompare') {
try {
const loadingMessage = ElMessage({
message: active ? '正在启用模型对比...' : '正在关闭模型对比...',
type: 'info',
duration: 0,
showClose: false,
})
await toggleCompareMode(active, mapStore.viewer)
loadingMessage.close()
ElMessage.success(active ? '模型对比已启用' : '模型对比已关闭')
} catch (error) {
console.error('切换模型对比模式失败:', error)
ElMessage.error({
message: `切换模型对比失败: ${error.message || '未知错误'}`,
duration: 3000,
})
}
}
}
/**
* 处理距离范围变更
*/
const handleDistanceChange = async (newDistance) => {
console.log(`[index.vue] 距离范围改变为: ${newDistance}km`)
// 更新搜索半径
disasterData.updateSearchRadius(newDistance)
// 更新范围圈
if (mapStore.viewer) {
createOrUpdateRangeCircle(mapStore.viewer, newDistance)
}
// 重新加载应急资源数据并更新地图标记
await loadEmergencyResources(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
// 重新加载储备中心和预置点数据并更新地图标记
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
}
// ====================
// 数据加载函数
// ====================
/**
* 加载养护站数据
*/
const loadEmergencyResources = async (longitude, latitude) => {
try {
const response = await request({
url: `/snow-ops-platform/disaster/matchEmergencyResources`,
method: 'GET',
params: {
longitude,
latitude,
maxDistance: disasterData.forcePreset.value.searchRadius,
},
})
if (response?.data) {
disasterData.updateForcePreset(response.data)
console.log('[index.vue] 应急资源数据加载成功:', response.data)
if (mapStore.viewer) {
console.log('[index.vue] 更新地图应急资源标记...')
clearEmergencyResourceMarkers(mapStore.viewer)
await addEmergencyResourceMarkers(mapStore.viewer, response.data, { longitude, latitude }, { heightOffset: 10 })
} else {
console.warn('[index.vue] 地图viewer未就绪跳过标记更新')
}
} else {
console.warn('[index.vue] 应急资源接口返回数据为空')
}
return response
} catch (error) {
console.error('[index.vue] 加载应急资源数据失败:', error)
ElMessage.warning({
message: '应急资源数据加载失败,使用默认数据',
duration: 3000,
})
return null
}
}
/**
* 加载储备中心和预置点数据
*/
const loadReserveCentersAndPresets = async (longitude, latitude) => {
try {
const response = await request({
url: `/snow-ops-platform/yhYjll/list`,
method: 'GET',
params: {
longitude,
latitude,
maxDistance: disasterData.forcePreset.value.searchRadius,
},
})
if (response?.data && Array.isArray(response.data)) {
console.log('[index.vue] 储备中心和预置点数据加载成功:', response.data)
if (mapStore.viewer) {
console.log('[index.vue] 添加储备中心和预置点地图标记...')
clearReserveCenterMarkers(mapStore.viewer)
await addReserveCenterMarkers(mapStore.viewer, response.data, { heightOffset: 10 })
} else {
console.warn('[index.vue] 地图viewer未就绪跳过标记更新')
}
} else {
console.warn('[index.vue] 储备中心和预置点接口返回数据为空')
}
return response
} catch (error) {
console.error('[index.vue] 加载储备中心和预置点数据失败:', error)
ElMessage.warning({
message: '储备中心和预置点数据加载失败',
duration: 3000,
})
return null
}
}
// ====================
// 场景初始化函数
// ====================
/**
* 初始化场景
*/
const initializeScene = async () => {
const viewer = mapStore.viewer
if (!viewer) {
console.error('[index.vue] viewer 未就绪')
return
}
console.log('[index.vue] 开始初始化场景...')
// 1. 启用模型对比功能(界面立即呈现,模型延迟加载)
try {
console.log('[index.vue] 启用模型对比功能(不加载左侧模型)...')
await toggleCompareMode(true, viewer, { skipLeftModelLoad: true })
console.log('[index.vue] 模型对比界面已启用')
} catch (error) {
console.error('[index.vue] 启用模型对比功能失败:', error)
}
// 2. 定位相机到目标位置
console.log('[index.vue] 设置相机到目标位置...')
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
DEFAULT_CAMERA_POSITION.lon,
DEFAULT_CAMERA_POSITION.lat,
DEFAULT_CAMERA_VIEW.height
),
orientation: {
heading: Cesium.Math.toRadians(DEFAULT_CAMERA_VIEW.heading),
pitch: Cesium.Math.toRadians(DEFAULT_CAMERA_VIEW.pitch),
roll: DEFAULT_CAMERA_VIEW.roll,
},
})
// 3. 添加模拟点位(人员和设备)
const allMockPoints = mockDataService.getAllMockPoints()
allMockPoints.forEach((point) => {
const config =
point.type === 'soldier'
? mockDataService.createPersonnelEntityConfig(point, soldierIcon)
: mockDataService.createDeviceEntityConfig(point, deviceIcon)
viewer.entities.add(config)
})
console.log(`[index.vue] 已添加 ${allMockPoints.length} 个模拟点位`)
// 4. 添加路径起点标记(用于"一键启动"
const allPaths = mockDataService.getAllAnimationPaths()
Object.entries(allPaths).forEach(([pathId, path]) => {
const icon = path.metadata.type === 'soldier' ? soldierIcon : deviceIcon
const config = mockDataService.createPathStartMarkerConfig(
{ ...path, id: pathId },
icon
)
viewer.entities.add(config)
})
console.log('[index.vue] 已添加 3 个路径起点标记')
// 5. 设置地图点击事件监听
mapClickHandler.setupClickHandler(viewer, registerEventHandler, registerPostRenderListener)
// 6. 加载右侧 3D Tiles灾后模型
try {
console.log('[index.vue] 开始加载3D模型...')
const afterTileset = await load3DTileset(viewer, 'after', false)
if (afterTileset) {
console.log('[index.vue] 等待3D模型完全就绪...')
await waitForTilesetReady(afterTileset)
console.log('[index.vue] 3D模型已完全就绪')
// 等待地形数据准备就绪
await new Promise((resolve) => setTimeout(resolve, 2000))
// 添加中心点标记
console.log('[index.vue] 添加中心点标记...')
console.log('[index.vue] 中心点标记位置:', DISASTER_CENTER.lon, DISASTER_CENTER.lat)
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(DISASTER_CENTER.lon, DISASTER_CENTER.lat, 0),
billboard: {
image: eventIcon,
width: 36,
height: 36,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
})
console.log('[index.vue] 中心点标记已添加')
}
} catch (error) {
console.error('[index.vue] 3D模型加载失败:', error)
}
// 7. 延迟加载左侧 3D Tiles灾前模型
try {
console.log('[index.vue] 开始加载左侧灾前模型...')
await toggleCompareMode(true, viewer, { loadLeftModel: true })
console.log('[index.vue] 左侧灾前模型加载完成')
} catch (error) {
console.error('[index.vue] 左侧模型加载失败:', error)
}
// 8. 初始化地图标记
try {
console.log('[index.vue] 开始初始化地图标记...')
await initializeMarkers(viewer, {
useSampledHeights: true,
heightOffset: 100,
})
console.log('[index.vue] 地图标记初始化完成')
} catch (error) {
console.error('[index.vue] 地图标记初始化失败:', error)
}
// 9. 创建范围圈
createOrUpdateRangeCircle(viewer, disasterData.forcePreset.value.searchRadius)
// 10. 额外等待确保地形完全就绪(避免标记悬浮)
console.log('[index.vue] 等待地形完全就绪...')
await new Promise(resolve => setTimeout(resolve, 1000))
// 11. 加载应急资源数据(在地形就绪后)
console.log('[index.vue] 加载应急资源数据...')
await loadEmergencyResources(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
// 12. 加载储备中心和预置点数据
console.log('[index.vue] 加载储备中心和预置点数据...')
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
console.log('[index.vue] 场景初始化完成')
}
// ====================
// 生命周期钩子
// ====================
/**
* 组件挂载
* 统一的初始化入口
*/
onMounted(() => {
// 等待地图就绪后初始化场景(数据加载已在 initializeScene 中完成)
mapStore.onReady(async () => {
console.log('[index.vue] 3D态势感知地图已就绪')
// 初始化场景(包含所有数据加载)
await initializeScene()
})
})
/**
* 组件卸载
* 清理所有资源
*/
onUnmounted(() => {
console.log('[index.vue] 组件卸载,开始清理资源...')
const viewer = mapStore.viewer
// 清理范围圈
if (viewer) {
clearRangeCircle(viewer)
}
// 清理地图点击处理器
mapClickHandler.destroy()
// 清理所有 Cesium 资源(由 useCesiumLifecycle 自动执行)
cleanup()
console.log('[index.vue] 资源清理完成')
})
// ====================
// Provide 给子组件
// ====================
provide('disasterData', disasterData)
provide('onDistanceChange', handleDistanceChange)
2025-11-19 17:06:05 +08:00
</script>
<style scoped lang="scss">
@use "@/styles/mixins.scss" as *;
@use "./assets/styles/common.scss" as *;
.situational-awareness {
// 容器查询设置,用于嵌入场景的自适应缩放
container-name: situational-awareness;
container-type: size;
// 为旧版浏览器提供视口单位回退,封顶 1920×1080
--cq-inline-100: clamp(0px, 100vw, 1920px);
--cq-block-100: clamp(0px, 100vh, 1080px);
// 当支持容器单位时覆盖为容器单位,同样封顶
@supports (width: 1cqw) {
--cq-inline-100: min(100cqw, 1920px);
}
@supports (height: 1cqh) {
--cq-block-100: min(100cqh, 1080px);
}
// 可配置的布局变量(使用 calc 直接计算,避免函数嵌套)
--sa-left-width: calc(464 / 1920 * var(--cq-inline-100, 100vw));
--sa-right-width: calc(486 / 1920 * var(--cq-inline-100, 100vw));
--sa-gap: calc(16 / 1920 * var(--cq-inline-100, 100vw));
--sa-padding: calc(16 / 1920 * var(--cq-inline-100, 100vw));
--sa-header-height: calc(
131 / 1080 * var(--cq-block-100, 100vh)
); // Header 高度
--sa-min-width: 1280px;
--sa-min-height: 720px;
position: relative;
width: 100%;
height: 100%;
// max-width: 1920px;
// max-height: 1080px;
min-width: var(--sa-min-width);
min-height: var(--sa-min-height);
background-color: var(--bg-dark);
overflow-x: hidden;
overflow-y: auto; // 当宿主尺寸 < 最小尺寸时允许滚动,达到上限时不放大
// PageHeader 浮在顶部
> :first-child {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
&__main {
position: absolute;
inset: 0; // 铺满整个容器
background: url(./assets/images/main-bg.png) center/cover no-repeat;
overflow: hidden;
}
// 地图底层 - 填满整个容器
&__map-layer {
position: absolute;
inset: 0;
z-index: 0;
display: flex;
// 默认单地图模式
&:not(.is-compare-mode) {
.situational-awareness__right-map {
width: 100%;
}
.situational-awareness__left-map {
width: 0;
2025-11-19 17:06:05 +08:00
opacity: 0;
pointer-events: none;
overflow: hidden;
}
}
// 双地图对比模式
&.is-compare-mode {
.situational-awareness__left-map {
width: 50%;
2025-11-19 17:06:05 +08:00
opacity: 1;
pointer-events: auto;
transition: width 0.3s ease, opacity 0.3s ease;
}
.situational-awareness__right-map {
width: 50%;
transition: width 0.3s ease;
}
}
}
// 左侧地图容器(灾前场景 - 对比时显示)
&__left-map {
position: relative;
height: 100%;
background: #000;
}
// 右侧地图容器(灾后场景 - 主地图)
&__right-map {
position: relative;
height: 100%;
}
// 中间分割线
&__center-divider {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.5),
rgba(255, 255, 255, 0.1)
);
z-index: 1;
pointer-events: none;
}
// 地图遮罩层 - 覆盖在地图之上,增强视觉效果
&__map-mask {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none; // 不阻挡交互
// 使用 cockpit 的遮罩层图片,保持视觉一致性
background: url(@/views/cockpit/assets/img/遮罩层.png) center/cover
no-repeat;
}
2025-11-19 17:06:05 +08:00
// 场景标签层 - 显示在遮罩层和面板层之上
&__scene-labels-layer {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none; // 标签不阻挡交互
}
// 浮动面板层 - 使用绝对定位固定面板位置
&__panels-layer {
position: absolute;
inset: 0;
z-index: 2;
height: 100%;
pointer-events: none; // 容器不拦截事件,让中间区域透明
}
// 左右面板列 - 浮动卡片样式
&__panel-column {
2025-11-19 17:06:05 +08:00
position: absolute;
// top: var(--sa-header-height); // 从 header 下方开始
// bottom: 0;
display: flex;
flex-direction: column;
gap: var(--sa-gap); // 列内子面板之间的间距
min-width: 0; // 防止在窄容器中溢出
min-height: 0; // 允许 flex 子元素收缩并启用滚动
pointer-events: auto; // 恢复面板的交互能力
2025-11-19 17:06:05 +08:00
// 左侧面板固定在左边
&--left {
left: 0;
width: var(--sa-left-width);
}
// 右侧面板固定在右边
&--right {
right: 0;
width: var(--sa-right-width);
}
}
// 中间占位区域 - 透明且不可交互,点击穿透到地图
&__center-spacer {
pointer-events: none;
}
2025-11-19 17:06:05 +08:00
// 折叠按钮层 - 独立层级,放置在屏幕两侧
&__collapse-buttons-layer {
position: absolute;
inset: 0;
z-index: 11; // 高于场景标签层
pointer-events: none; // 容器不拦截事件
padding-top: var(--sa-header-height); // 预留 Header 高度
}
// 折叠按钮
&__collapse-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 11;
padding: 0;
&:hover {
opacity: 0.8;
}
&:active {
transform: translateY(-50%) scale(0.95);
}
.collapse-arrow {
width: vw(30);
height: auto;
transition: all 0.3s ease;
}
// 左侧按钮 - 固定在屏幕最左侧
&--left {
left: 0;
}
// 右侧按钮 - 固定在屏幕最右侧
&--right {
right: 0;
}
}
// 面板滑动动画 - 左侧
.panel-slide-left-enter-active,
.panel-slide-left-leave-active {
transition: all 0.3s ease;
}
.panel-slide-left-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.panel-slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
// 面板滑动动画 - 右侧
.panel-slide-right-enter-active,
.panel-slide-right-leave-active {
transition: all 0.3s ease;
}
.panel-slide-right-enter-from {
transform: translateX(100%);
opacity: 0;
}
.panel-slide-right-leave-to {
transform: translateX(100%);
opacity: 0;
}
// 地图控件层 - 高于遮罩和面板,用于放置地图控制工具
&__controls-layer {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none; // 容器不拦截事件
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 30px; // 临时使用固定值,确保控件显示
}
// 控件容器 - 恢复交互能力
&__controls {
pointer-events: auto;
position: relative;
// 调试:确保控件容器可见
min-height: 56px; // MapControls 的高度
}
// 地图 Tooltip 层 - 覆盖地图和面板,仅 Tooltip 自身可交互
&__tooltip-layer {
position: absolute;
inset: 0;
z-index: 4; // 高于控件层
pointer-events: none; // 容器不拦截事件,点击穿透到地图
}
// 加载动画层 - 一键启动后显示
&__loading-layer {
position: absolute;
top: calc(var(--sa-header-height) + vh(40));
left: 0;
right: 0;
// bottom: 0;
z-index: 5; // 高于 Tooltip 层
display: flex;
align-items: center;
justify-content: center;
pointer-events: none; // 不阻止点击穿透
}
// 加载 GIF 图片
&__loading-gif {
width: auto;
height: auto;
max-width: 320px;
max-height: 55px;
object-fit: contain;
}
}
// Tooltip 内容字段样式
// 用于在 Tooltip 插槽中展示字段列表
.tooltip-field-item {
display: flex;
align-items: baseline;
gap: vw(8);
padding: vh(6) 0;
.tooltip-field-label {
color: var(--text-gray);
font-size: fs(13);
font-family: SourceHanSansCN-Regular, sans-serif;
white-space: nowrap;
flex-shrink: 0;
}
.tooltip-field-value {
color: var(--text-white);
font-size: fs(13);
font-family: SourceHanSansCN-Medium, sans-serif;
font-weight: 500;
flex: 1;
word-break: break-all;
}
}
// Tooltip 操作按钮样式
// 用于"连线"、"联动"等交互按钮
.tooltip-action-btn {
min-width: vw(100);
height: vh(36);
padding: 0 vw(24);
background: url('./assets/images/地图tooltip-button.png') no-repeat center/100% 100%;
border: none;
color: var(--text-white);
font-size: fs(14);
font-family: SourceHanSansCN-Medium, sans-serif;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
filter: brightness(1.2);
transform: translateY(vh(-2));
}
&:active {
filter: brightness(0.9);
transform: translateY(0);
}
}
// 窄容器嵌入的紧凑布局(<1100px 宽度)
.situational-awareness.is-compact {
--sa-left-width: calc(380 / 1920 * var(--cq-inline-100, 100vw));
--sa-right-width: calc(400 / 1920 * var(--cq-inline-100, 100vw));
--sa-gap: calc(12 / 1920 * var(--cq-inline-100, 100vw));
--sa-padding: calc(12 / 1920 * var(--cq-inline-100, 100vw));
}
// 嵌入模式 - 使用更保守的最小尺寸
.situational-awareness.is-embedded {
--sa-min-width: 1024px;
--sa-min-height: 600px;
}
</style>