2025-11-25 10:09:59 +08:00

1229 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">
<MapViewer @tool-change="handleMapToolChange" />
</div>
</div>
<!-- 地图遮罩层 -->
<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">
<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"
/>
</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 ? '展开左侧面板' : '收起左侧面板'"
>
<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 ? '展开右侧面板' : '收起右侧面板'"
>
<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
: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/加载3.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
:visible="showStretchableModal"
title="公路灾害现场处置推荐方案"
:close-on-click-modal="true"
:close-on-press-escape="true"
:show-close="true"
@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,
})
// ====================
// 事件处理函数
// ====================
/**
* 切换左侧面板
*/
const toggleLeftPanel = () => {
isLeftPanelCollapsed.value = !isLeftPanelCollapsed.value
}
/**
* 切换右侧面板
*/
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)
</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;
opacity: 0;
pointer-events: none;
overflow: hidden;
}
}
// 双地图对比模式
&.is-compare-mode {
.situational-awareness__left-map {
width: 50%;
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;
}
// 场景标签层 - 显示在遮罩层和面板层之上
&__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 {
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; // 恢复面板的交互能力
// 左侧面板固定在左边
&--left {
left: 0;
width: var(--sa-left-width);
}
// 右侧面板固定在右边
&--right {
right: 0;
width: var(--sa-right-width);
}
}
// 中间占位区域 - 透明且不可交互,点击穿透到地图
&__center-spacer {
pointer-events: none;
}
// 折叠按钮层 - 独立层级,放置在屏幕两侧
&__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>