2043 lines
58 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
:active-tool-key="activeToolKey"
@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"
@force-preset-toggle="handleForcePresetToggle"
@quick-response-toggle="handleQuickResponseToggle"
/>
</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="inModal"
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, watch } 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 { useEmergencyRouteSelection } from "./composables/useEmergencyRouteSelection";
import { useSimulatedMarkers } from "./composables/useSimulatedMarkers";
// ========== 工具和常量导入 ==========
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 { calculateDistance, isValidCoordinate } from "./utils/geoUtils";
// ========== 图标资源导入 ==========
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 cityEmergencyIcon from "./assets/images/市应急点.png";
import districtEmergencyIcon from "./assets/images/区县应急点.png";
import otherEmergencyIcon 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();
// 地图viewer
const cesiumViewer = ref(null);
// ====================
// 生命周期管理
// ====================
// Cesium 资源生命周期管理
const {
registerEventHandler,
registerPostRenderListener,
registerTimeout,
cleanup,
} = useCesiumLifecycle();
// ====================
// 地图功能模块
// ====================
// 双地图对比功能
const { isCompareMode, toggleCompareMode } = useDualMapCompare();
// 地图标记功能
const {
initializeMarkers,
addEmergencyResourceMarkers,
clearEmergencyResourceMarkers,
addReserveCenterMarkers,
clearReserveCenterMarkers,
showMarkers,
hideMarkers,
markerEntities,
reserveCenterEntities,
emergencyResourceEntities,
drawCollapseBoundary,
drawCollapseBoundaryLeft,
clearCollapseBoundary,
showCollapseBoundary,
hideCollapseBoundary,
triggerJumpAnimation,
} = 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,
showRangeCircle,
hideRangeCircle,
} = rangeCircleComposable;
// 路径线管理
const pathLinesComposable = usePathLines();
// 地图点击事件处理
const mapClickHandler = useMapClickHandler({
tooltipComposable,
icons: {
soldierIcon,
deviceIcon,
emergencyCenterIcon,
emergencyBaseIcon,
reserveCenterIcon,
cityEmergencyIcon,
districtEmergencyIcon,
otherEmergencyIcon,
},
rangeCircleEntity,
});
// 应急调度流程
const { showLoading, loadingMessage, startDispatch, startDispatchWithRouting } =
useEmergencyDispatch({
pathLinesComposable,
entityAnimationComposable,
mapStore,
registerTimeoutFn: registerTimeout,
});
// 应急点选择
const { selectByType } = useEmergencyRouteSelection();
// 模拟标记管理
const {
createSimulatedMarkers,
clearSimulatedMarkers,
hideEmergencyMarkers,
getEmergencyMarkerData,
} = useSimulatedMarkers();
// ====================
// UI 状态
// ====================
// 面板折叠状态
const isLeftPanelCollapsed = ref(false);
const isRightPanelCollapsed = ref(false);
// 标记点和范围圈显示状态
const showMarkersAndRange = ref(false);
// 快速响应执行状态
const quickResponseExecuted = ref(false);
// 地图工具激活状态 - 默认激活模型对比
const activeToolKey = ref("modelCompare");
// 工具键常量
const COMPARE_TOOL_KEY = "modelCompare";
// 对比模式切换锁,防止并发操作
const isCompareTogglePending = 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 = async (payload) => {
console.log("[index.vue] 启动响应调度");
// Check if Quick Response has been executed
if (!quickResponseExecuted.value) {
console.log("[index.vue] 快速响应未执行,自动执行...");
await executeQuickResponse();
quickResponseExecuted.value = true;
}
// Get emergency marker data
const emergencyMarkerData = getEmergencyMarkerData();
if (emergencyMarkerData.length === 0) {
ElMessage.error("未找到应急点数据");
return;
}
// Start dispatch with routing using emergency points
startDispatchWithRouting({
emergencyPoints: emergencyMarkerData.map((marker) => ({
lon: marker.markerCoords.lon,
lat: marker.markerCoords.lat,
type: marker.type === "emergencyPersonnel" ? "personnel" : "equipment",
name: marker.name,
})),
});
};
/**
* 处理查看智能应急方案事件
*/
const handleViewPlan = (plan) => {
console.log("[index.vue] 查看智能应急方案:", plan);
showStretchableModal.value = true;
};
/**
* 处理弹窗中的一键启动按钮点击事件
*/
const handleModalStartDispatch = async () => {
console.log("[index.vue] 弹窗中点击一键启动 - 使用智能路径规划");
showStretchableModal.value = false;
// Check if Quick Response has been executed
if (!quickResponseExecuted.value) {
console.log("[index.vue] 快速响应未执行,自动执行...");
await executeQuickResponse();
quickResponseExecuted.value = true;
}
// Get emergency marker data
const emergencyMarkerData = getEmergencyMarkerData();
if (emergencyMarkerData.length === 0) {
ElMessage.error("未找到应急点数据");
return;
}
// Use emergency marker data for routing
startDispatchWithRouting({
emergencyPoints: emergencyMarkerData.map((marker) => ({
lon: marker.markerCoords.lon,
lat: marker.markerCoords.lat,
type: marker.type === "emergencyPersonnel" ? "personnel" : "equipment",
name: marker.name,
})),
});
};
/**
* 统一的对比模式状态管理助手
* 职责:
* 1. 同步管理 activeToolKey 和 isCompareMode
* 2. 提供失败回滚机制
* 3. 防止并发操作
*
* @param {boolean} shouldActivate - 是否激活对比模式
* @param {string} source - 调用来源(用于日志追踪)
* @returns {Promise<boolean>} 操作是否成功
*/
const setCompareToolState = async (shouldActivate, source = "unknown") => {
// 1. 并发保护
if (isCompareTogglePending.value) {
console.warn(`[index.vue] ${source} - 对比模式正在切换中,忽略本次操作`);
return false;
}
if (isCompareMode.value === shouldActivate) {
console.log(
`[index.vue] ${source} - 对比模式已是目标状态 (${shouldActivate}),无需操作`
);
return true;
}
// 2. 保存当前状态(用于失败回滚)
const prevToolKey = activeToolKey.value;
const prevCompareMode = isCompareMode.value;
// 3. 设置锁
isCompareTogglePending.value = true;
// 4. 乐观更新UI状态(立即响应)
activeToolKey.value = shouldActivate ? COMPARE_TOOL_KEY : null;
try {
// 5. 执行实际的模式切换
console.log(`[index.vue] ${source} - 开始切换对比模式: ${shouldActivate}`);
await toggleCompareMode(shouldActivate, mapStore.viewer);
console.log(`[index.vue] ${source} - 对比模式切换成功`);
return true;
} catch (error) {
// 6. 失败回滚
console.error(`[index.vue] ${source} - 切换对比模式失败:`, error);
activeToolKey.value = prevToolKey;
if (isCompareMode.value !== prevCompareMode) {
console.warn(`[index.vue] isCompareMode状态不一致,强制回滚`);
isCompareMode.value = prevCompareMode;
}
ElMessage.error({
message: `切换对比模式失败: ${error.message || "未知错误"}`,
duration: 3000,
});
return false;
} finally {
// 7. 释放锁
isCompareTogglePending.value = false;
}
};
/**
* 处理快速匹配标题点击事件 - 切换显示/隐藏标记和范围圈
*/
const handleForcePresetToggle = async () => {
console.log(
"[index.vue] 快速匹配标题点击, 当前状态:",
showMarkersAndRange.value ? "已显示" : "已隐藏"
);
// 切换显示状态
if (!showMarkersAndRange.value) {
// 当前隐藏,切换为显示
// 1. 关闭地图对比模式(使用统一助手)
if (isCompareMode.value) {
console.log("[index.vue] 快速匹配需要关闭对比模式");
const success = await setCompareToolState(false, "force-preset");
if (!success) {
// 如果关闭对比模式失败,不继续执行后续操作
console.error("[index.vue] 关闭地图对比模式失败,中止快速匹配操作");
return;
}
console.log("[index.vue] 已关闭地图对比模式");
}
// 2. 显示所有标记和范围圈(现在是异步函数)
await showAllMarkersAndRange();
// 3. 调整相机到最佳视角
flyToBestViewForMarkers();
// 更新状态
showMarkersAndRange.value = true;
} else {
// 当前显示,切换为隐藏
hideAllMarkersAndRange();
// 更新状态
showMarkersAndRange.value = false;
}
};
/**
* 处理快速响应标题点击事件
*/
const handleQuickResponseToggle = async () => {
console.log("[index.vue] 快速响应标题点击");
if (!quickResponseExecuted.value) {
// Execute Quick Response
await executeQuickResponse();
quickResponseExecuted.value = true;
} else {
// Hide Quick Response markers
hideQuickResponse();
quickResponseExecuted.value = false;
}
};
/**
* 辅助函数:判断是否为应急点标记
*/
const isEmergencyPointMarker = (type) => {
return (
type === "cityEmergency" ||
type === "districtEmergency" ||
type === "otherEmergency"
);
};
/**
* 执行快速响应逻辑
*/
const executeQuickResponse = async () => {
const viewer = mapStore.viewer;
if (!viewer) return;
console.log("[index.vue] 执行快速响应...");
// 1. Hide all other markers
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const type = entity.properties.type?.getValue();
const isSimulated = entity.properties.isSimulated?.getValue();
// Hide non-simulated, non-emergency-point markers
if (!isSimulated && !isEmergencyPointMarker(type)) {
entity.show = false;
}
}
});
// Hide养护站等 emergency resource markers
hideMarkers();
// 2. Load emergency points within range
const rangeResponse = await request({
url: `/snow-ops-platform/yhYjll/list`,
method: "GET",
params: {
longitude: DISASTER_CENTER.lon,
latitude: DISASTER_CENTER.lat,
maxDistance: disasterData.forcePreset.value.searchRadius, // 30 or 50 km
},
});
if (!rangeResponse?.data || !Array.isArray(rangeResponse.data)) {
ElMessage.error("加载应急点失败");
return;
}
// 3. Display emergency points on map
await displayEmergencyPoints(rangeResponse.data);
// 4. Select 2 nearest emergency points
const emergencyPoints = rangeResponse.data;
const pointsWithDistance = emergencyPoints.map((point) => ({
...point,
distance: calculateDistance(
{ lon: DISASTER_CENTER.lon, lat: DISASTER_CENTER.lat },
{ lon: point.gl1Lng, lat: point.gl1Lat }
),
}));
const sortedPoints = pointsWithDistance.sort(
(a, b) => a.distance - b.distance
);
const nearest2Points = sortedPoints.slice(0, 2);
if (nearest2Points.length < 2) {
ElMessage.warning("应急点数量不足2个");
return;
}
console.log(
"[index.vue] 选择最近的2个应急点:",
nearest2Points.map((p) => p.gl1Yjllmc)
);
// 5. Create 6 simulated markers
createSimulatedMarkers(viewer, DISASTER_CENTER, nearest2Points);
// 6. Force render
viewer.scene.requestRender();
console.log("[index.vue] 快速响应执行完成");
};
/**
* 显示应急点标记
*/
const displayEmergencyPoints = async (emergencyPoints) => {
const viewer = mapStore.viewer;
if (!viewer) return;
// Clear existing emergency point markers
viewer.entities.values.forEach((entity) => {
const type = entity.properties?.type?.getValue();
if (isEmergencyPointMarker(type)) {
viewer.entities.remove(entity);
}
});
// Add new emergency point markers
emergencyPoints.forEach((point) => {
// Determine icon based on level
const levelString = String(point.gl1Lx || "").trim();
let icon = otherEmergencyIcon;
let type = "otherEmergency";
let levelName = "其他";
if (levelString.startsWith("1") || levelString === "1") {
icon = cityEmergencyIcon;
type = "cityEmergency";
levelName = "市级";
} else if (levelString.startsWith("2") || levelString === "2") {
icon = districtEmergencyIcon;
type = "districtEmergency";
levelName = "区县级";
}
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point.gl1Lng, point.gl1Lat, 0),
billboard: {
image: icon,
width: 29,
height: 32,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: new Cesium.PropertyBag({
type: type,
name: point.gl1Yjllmc,
level: levelName,
district: point.gl1Qxmc,
personnel: point.gl1Rysl,
area: point.gl1Zdmj,
}),
});
});
console.log(`[index.vue] 显示 ${emergencyPoints.length} 个应急点`);
};
/**
* 隐藏快速响应标记
*/
const hideQuickResponse = () => {
const viewer = mapStore.viewer;
if (!viewer) return;
console.log("[index.vue] 隐藏快速响应标记...");
// Hide emergency point markers
viewer.entities.values.forEach((entity) => {
const type = entity.properties?.type?.getValue();
if (isEmergencyPointMarker(type)) {
entity.show = false;
}
});
// Clear simulated markers
clearSimulatedMarkers(viewer);
console.log("[index.vue] 已隐藏快速响应标记");
};
/**
* 处理地图工具变化事件
*/
const handleMapToolChange = async ({ tool, active }) => {
console.log(`[index.vue] 地图工具变化: ${tool}, 激活状态: ${active}`);
if (tool === COMPARE_TOOL_KEY) {
// 模型对比工具: 使用统一助手管理状态
const loadingMessage = ElMessage({
message: active ? "正在启用模型对比..." : "正在关闭模型对比...",
type: "info",
duration: 0,
showClose: false,
});
const success = await setCompareToolState(active, "map-controls");
loadingMessage.close();
if (success) {
ElMessage.success(active ? "模型对比已启用" : "模型对比已关闭");
}
// 失败消息已在助手函数中处理
} else {
// 其他工具: 保持原有逻辑
activeToolKey.value = active ? tool : null;
console.log(`[index.vue] 工具 ${tool} ${active ? "激活" : "取消"}`);
}
};
/**
* 处理距离范围变更
*/
const handleDistanceChange = async (newDistance) => {
console.log(`[index.vue] 距离范围改变为: ${newDistance}km`);
// 1. 更新搜索半径
disasterData.updateSearchRadius(newDistance);
// 2. 更新范围圈
if (mapStore.viewer) {
createOrUpdateRangeCircle(mapStore.viewer, newDistance);
}
// 3. 重新加载应急资源数据并更新地图标记
// await loadEmergencyResources(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
// 4. 重新加载储备中心和预置点数据并更新地图标记
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat);
// 5. 重新加载应急统计数据 (新增)
await loadEmergencyBaseAndPreset(DISASTER_CENTER.lon, DISASTER_CENTER.lat);
};
/**
* 显示所有标记点和范围圈
*/
const showAllMarkersAndRange = async () => {
const viewer = mapStore.viewer;
if (!viewer) return;
// 1. 显示模拟点位
// viewer.entities.values.forEach((entity) => {
// if (entity.properties) {
// const props = entity.properties
// if (props.type?.getValue() === 'soldier' ||
// props.type?.getValue() === 'device' ||
// props.isPathStartMarker?.getValue()) {
// entity.show = true
// }
// }
// })
// 2. 显示接口标记
// hideMarkers()
// 2. 加载全部储备中心/预置点到地图(不限制距离)
console.log("[index.vue] 加载全部储备中心/预置点到地图...");
await loadReserveCentersAndPresets(
DISASTER_CENTER.lon,
DISASTER_CENTER.lat,
true
);
// 3. 显示范围圈
showRangeCircle();
console.log("[index.vue] 已显示所有标记点和范围圈");
};
/**
* 隐藏所有标记点和范围圈
*/
const hideAllMarkersAndRange = () => {
const viewer = mapStore.viewer;
if (!viewer) return;
// 1. 隐藏模拟点位
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const props = entity.properties;
if (
props.type?.getValue() === "soldier" ||
props.type?.getValue() === "device" ||
props.isPathStartMarker?.getValue()
) {
entity.show = false;
}
}
});
// 2. 隐藏接口标记
hideMarkers();
// 3. 隐藏范围圈
hideRangeCircle();
console.log("[index.vue] 已隐藏所有标记点和范围圈");
};
/**
* 在选中的应急点位置显示人员/装备初始标记
* @param {Array} personnelPoints - 选中的人员应急点
* @param {Array} equipmentPoints - 选中的装备应急点
*/
const showEmergencyPointMarkers = (personnelPoints, equipmentPoints) => {
const viewer = mapStore.viewer;
if (!viewer) return;
console.log("🔥🔥🔥 [NEW CODE] 在应急点位置添加人员/装备标记 🔥🔥🔥");
// 清除旧的应急点标记
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const type = entity.properties.type?.getValue();
if (type === "emergencyPersonnel" || type === "emergencyEquipment") {
entity.show = false;
}
}
});
// 添加人员标记
personnelPoints.forEach((point, index) => {
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
billboard: {
image: soldierIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: new Cesium.PropertyBag({
type: "emergencyPersonnel",
name: point.name || `应急人员${index + 1}`,
emergencyPointId: point.id,
}),
show: true,
});
console.log(`[index.vue] 已添加人员标记: ${point.name}`);
});
// 添加装备标记
equipmentPoints.forEach((point, index) => {
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
billboard: {
image: deviceIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
properties: new Cesium.PropertyBag({
type: "emergencyEquipment",
name: point.name || `应急装备${index + 1}`,
emergencyPointId: point.id,
}),
show: true,
});
console.log(`[index.vue] 已添加装备标记: ${point.name}`);
});
viewer.scene.requestRender();
console.log("[index.vue] 应急点标记显示完成");
};
/**
* 调整相机到显示范围圈区域
* 根据当前搜索半径动态调整视角
*/
const flyToBestViewForMarkers = () => {
const viewer = mapStore.viewer;
if (!viewer) return;
const { camera } = mapStore.services();
// 获取当前搜索半径(公里)
const radiusKm = disasterData.forcePreset.value.searchRadius;
const radiusMeters = radiusKm * 1000;
// 范围圈中心点
const centerLon = DISASTER_CENTER.lon;
const centerLat = DISASTER_CENTER.lat;
// 计算范围圈边界的4个点(东西南北)
// 1度纬度 ≈ 111km
const latOffset = radiusKm / 111.32;
// 1度经度 ≈ 111km * cos(纬度)
const lonOffset =
radiusKm / (111.32 * Math.cos(Cesium.Math.toRadians(centerLat)));
const boundaryPoints = [
{ lon: centerLon, lat: centerLat + latOffset }, // 北
{ lon: centerLon + lonOffset, lat: centerLat }, // 东
{ lon: centerLon, lat: centerLat - latOffset }, // 南
{ lon: centerLon - lonOffset, lat: centerLat }, // 西
{ lon: centerLon, lat: centerLat }, // 中心
];
// 使用智能聚焦方法飞向范围圈区域
camera.fitBoundsWithTrajectory(boundaryPoints, {
duration: 2, // 2秒飞行时间
padding: 0.1, // 10%边距,确保范围圈完整显示
});
console.log(`[index.vue] 相机已调整到范围圈视角 (半径: ${radiusKm}km)`);
};
// ====================
// 数据加载函数
// ====================
/**
* 加载养护站数据
*/
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;
}
};
/**
* 加载储备中心和预置点数据
* @param {number} longitude - 经度(可选,不传则查询全部)
* @param {number} latitude - 纬度(可选,不传则查询全部)
* @param {boolean} loadAllForMap - 是否加载全部点位到地图true时不限制距离
*/
const loadReserveCentersAndPresets = async (
longitude,
latitude,
loadAllForMap = false
) => {
try {
// 构建请求参数
const params = {};
if (!loadAllForMap && longitude !== undefined && latitude !== undefined) {
// 正常模式:传递经纬度和距离限制
params.longitude = longitude;
params.latitude = latitude;
params.maxDistance = disasterData.forcePreset.value.searchRadius;
}
// loadAllForMap 为 true 时,不传任何参数,获取全部数据
const response = await request({
url: `/snow-ops-platform/yhYjll/list`,
method: "GET",
params,
});
if (response?.data && Array.isArray(response.data)) {
console.log(
"[index.vue] 储备中心和预置点数据加载成功:",
response.data.length,
"个点位"
);
// 1. 转换数据为标准 stations 格式
const transformedStations = disasterData.transformReserveDataToStations(
response.data,
{ longitude, latitude }
);
// 2. 仅在非"全部加载"模式下更新 forcePreset.stations用于 station-list 显示)
if (!loadAllForMap && transformedStations.length > 0) {
disasterData.forcePreset.value.stations = transformedStations;
console.log(
"[index.vue] 已更新 forcePreset.stations:",
transformedStations.length,
"个"
);
}
// 3. 添加地图标记(全部或范围内)
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;
}
};
/**
* 加载应急基地与预置点、应急装备、应急物资、应急人员的统计数量
* /snow-ops-platform/yhYjll/statistics
*/
const loadEmergencyBaseAndPreset = async (longitude, latitude) => {
try {
const response = await request({
url: `/snow-ops-platform/yhYjll/statistics`,
method: "GET",
params: {
longitude,
latitude,
maxDistance: disasterData.forcePreset.value.searchRadius,
},
});
if (response?.data) {
// 保存统计数据到状态 (仅更新统计,不更新stations)
disasterData.updateForcePreset(response.data, { onlyStatistics: true });
console.log("[index.vue] 应急统计数据加载成功:", response.data);
} else {
console.warn("[index.vue] 应急统计数据接口返回数据为空");
}
return response;
} catch (error) {
console.error("[index.vue] 加载应急统计数据失败:", error);
ElMessage.warning({
message: "应急统计数据加载失败",
duration: 3000,
});
return null;
}
};
/**
* 查询应急装备和应急物资列表 /snow-ops-platform/yhYjll/listMaterials
*/
const loadEmergencyEquipmentAndMaterial = async (longitude, latitude) => {
try {
const response = await request({
url: `/snow-ops-platform/yhYjll/listMaterials`,
method: "GET",
params: {
longitude,
latitude,
maxDistance: disasterData.forcePreset.value.searchRadius,
},
});
if (response?.data) {
console.log("[index.vue] 应急装备和应急物资列表加载成功:", response.data);
} else {
console.warn("[index.vue] 应急装备和应急物资列表接口返回数据为空");
}
return response;
} catch (error) {
console.error("[index.vue] 查询应急装备和应急物资列表失败:", error);
ElMessage.warning({
message: "应急装备和应急物资列表数据加载失败",
duration: 3000,
});
return null;
}
};
loadEmergencyEquipmentAndMaterial(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
.then((response) => {
console.log("[index.vue] 应急装备和应急物资列表加载成功:", response.data);
})
.catch((error) => {
console.error("[index.vue] 查询应急装备和应急物资列表失败:", error);
});
// ====================
// 场景初始化函数
// ====================
/**
* 初始化场景
*/
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. 添加模拟点位(人员和设备)- 已移至步骤10.5(地形就绪后)
// 原因:在地形加载前添加会导致相机飞行时标记悬浮
// 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. 添加路径起点标记(用于"一键启动"- 已移至步骤10.5(地形就绪后)
// 原因:在地形加载前添加会导致相机飞行时标记悬浮
// 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: 60,
height: 60,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
priority: Number.MAX_SAFE_INTEGER, // 保证在所有模型之上
});
console.log("[index.vue] 中心点标记已添加");
// 绘制坍塌边界红色实线
console.log("[index.vue] 绘制坍塌边界红色实线...");
drawCollapseBoundary(viewer);
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] 左侧灾前模型加载完成");
// 在左侧地图绘制红色虚线边界
// const leftContainer = document.getElementById('leftCesiumContainer')
// if (leftContainer) {
// console.log('[index.vue] 绘制左侧坍塌边界红色虚线...')
// drawCollapseBoundaryLeft(leftContainer)
// 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
);
// 默认隐藏范围圈(等待快速匹配激活)
hideRangeCircle();
console.log("[index.vue] 已隐藏范围圈(等待快速匹配激活)");
// 10. 额外等待确保地形完全就绪(避免标记悬浮)
console.log("[index.vue] 等待地形完全就绪...");
await new Promise((resolve) => setTimeout(resolve, 1000));
// 10.5. 添加模拟点位和路径起点标记(在地形就绪后)
console.log("[index.vue] 添加模拟点位...");
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} 个模拟点位`);
// 添加路径起点标记(用于"一键启动"
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 个路径起点标记");
// 触发立即渲染,确保 CLAMP_TO_GROUND 生效
viewer.scene.requestRender();
// 默认隐藏模拟点位(等待快速匹配点击时显示)
viewer.entities.values.forEach((entity) => {
if (entity.properties) {
const props = entity.properties;
// 隐藏模拟点位(soldier/device)和路径起点标记
if (
props.type?.getValue() === "soldier" ||
props.type?.getValue() === "device" ||
props.isPathStartMarker?.getValue()
) {
entity.show = false;
}
}
});
console.log("[index.vue] 已隐藏模拟点位(等待快速匹配激活)");
// 11. 加载应急资源数据(在地形就绪后)
console.log("[index.vue] 加载应急资源数据...");
// await loadEmergencyResources(DISASTER_CENTER.lon, DISASTER_CENTER.lat)
// 默认隐藏接口标记(等待快速匹配激活)
hideMarkers();
console.log("[index.vue] 已隐藏应急资源标记(等待快速匹配激活)");
// 12. 加载储备中心和预置点数据
console.log("[index.vue] 加载储备中心和预置点数据...");
await loadReserveCentersAndPresets(DISASTER_CENTER.lon, DISASTER_CENTER.lat);
// 13. 加载应急统计数据
console.log("[index.vue] 加载应急统计数据...");
await loadEmergencyBaseAndPreset(DISASTER_CENTER.lon, DISASTER_CENTER.lat);
cesiumViewer.value = viewer;
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;
const leftContainer = document.getElementById("leftCesiumContainer");
// 清理范围圈
if (viewer) {
clearRangeCircle(viewer);
// 清理坍塌边界线(包括左右两侧)
clearCollapseBoundary(viewer, leftContainer);
}
// 清理地图点击处理器
mapClickHandler.destroy();
// 清理所有 Cesium 资源(由 useCesiumLifecycle 自动执行)
cleanup();
console.log("[index.vue] 资源清理完成");
});
// ====================
// 防御性watch - 确保状态始终一致
// ====================
// 作为保险机制,确保即使有新入口直接操作useDualMapCompare,状态也能保持一致
watch(
isCompareMode,
(newValue) => {
if (!newValue && activeToolKey.value === COMPARE_TOOL_KEY) {
console.warn("[index.vue] 检测到对比模式被外部关闭,同步更新工具状态");
activeToolKey.value = null;
}
},
{ immediate: false }
);
// ====================
// Provide 给子组件
// ====================
provide("disasterData", disasterData);
provide("onDistanceChange", handleDistanceChange);
// 提供 viewer 和动画方法
provide("cesiumViewer", cesiumViewer);
provide("triggerJump", (duration = 5, height = 30) => {
triggerJumpAnimation(cesiumViewer.value, duration, height);
});
</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; // 恢复面板的交互能力
height: 100%;
// 左侧面板固定在左边
&--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>