2025-11-19 09:29:47 +08:00

786 lines
23 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__panels-layer">
<div
class="situational-awareness__panel-column situational-awareness__panel-column--left"
>
<LeftPanel />
</div>
<div
class="situational-awareness__center-spacer"
aria-hidden="true"
></div>
<div
class="situational-awareness__panel-column situational-awareness__panel-column--right"
>
<RightPanel />
</div>
</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
v-model:visible="mapTooltip.visible"
:x="mapTooltip.x"
:y="mapTooltip.y"
:title="mapTooltip.title"
:icon="mapTooltip.icon"
:z-index="mapTooltip.zIndex"
@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>
</MapTooltip>
</div>
</div>
<!-- 弹窗组件 -->
<PersonnelDetail
:visible="showPersonnelDetail"
:personnel-data="selectedPersonnel"
@close="showPersonnelDetail = false"
@link="handlePersonnelLink"
/>
<EmergencyCenterDetail
:visible="showCenterDetail"
:center-data="selectedCenter"
@close="showCenterDetail = false"
/>
</div>
</template>
<script setup>
import { ref, provide, onMounted } 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 MapTooltip from "./components/shared/MapTooltip.vue";
import { useDisasterData } from "./composables/useDisasterData";
import { useDualMapCompare } from "./composables/useDualMapCompare";
import { useMapMarkers } from "./composables/useMapMarkers";
import { use3DTiles } from "./composables/use3DTiles";
import { useMapStore } from "@/map";
import { request } from "@shared/utils/request";
// 标记点
import emergencyCenterIcon from "./assets/images/应急中心.png";
// 使用灾害数据
const disasterData = useDisasterData();
// 处理距离范围变更
const handleDistanceChange = async (newDistance) => {
console.log(`[index.vue] 距离范围改变为: ${newDistance}km`);
// 更新搜索半径
disasterData.updateSearchRadius(newDistance);
// 重新加载应急资源数据并更新地图标记
await loadEmergencyResources(108.011506, 30.175827);
};
// 提供给子组件使用
provide("disasterData", disasterData);
provide("onDistanceChange", handleDistanceChange);
// 地图 store
const mapStore = useMapStore();
// 双地图对比功能
const { isCompareMode, toggleCompareMode } = useDualMapCompare();
// 地图标记功能
const {
initializeMarkers,
clearMarkers,
getCollapseCenter,
addEmergencyResourceMarkers,
clearEmergencyResourceMarkers,
} = useMapMarkers();
// 3D Tiles加载功能
const { load3DTileset, waitForTilesetReady } = use3DTiles();
// 初始化地图
onMounted(() => {
// 等待地图就绪后配置初始视图和模型对比图层
mapStore.onReady(async () => {
const { camera } = mapStore.services();
const viewer = mapStore.viewer;
console.log("3D态势感知地图已就绪");
// 默认相机配置
const DEFAULT_CAMERA_VIEW = {
lon: 108.011506,
lat: 30.175827,
height: 5000,
heading: 0,
pitch: -45,
roll: 0,
};
// 默认点加上图标标记,使用图片图标,图标路径为 packages\screen\src\views\3DSituationalAwarenessRefactor\assets\images\应急基地.png
const defaultPoint = new Cesium.Entity({
position: Cesium.Cartesian3.fromDegrees(
DEFAULT_CAMERA_VIEW.lon,
DEFAULT_CAMERA_VIEW.lat,
0
),
billboard: {
image: emergencyCenterIcon,
width: 36,
height: 40,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
viewer.entities.add(defaultPoint);
// camera.setView({
// ...DEFAULT_CAMERA_VIEW,
// });
camera.flyTo({
...DEFAULT_CAMERA_VIEW,
duration: 1,
});
// 延迟 1000ms 后设置相机到默认位置
// setTimeout(() => {
// camera.flyTo({
// ...DEFAULT_CAMERA_VIEW,
// duration: 1,
// });
// }, 5000);
return;
/**
* 设置相机到指定的笛卡尔坐标
* @param {Cesium.Cartesian3 | null} cartesian - 目标位置
* @returns {boolean} 是否成功设置
*/
const focusCameraOnCartesian = (cartesian) => {
if (!cartesian) return false;
const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
const centerLon = Cesium.Math.toDegrees(cartographic.longitude);
const centerLat = Cesium.Math.toDegrees(cartographic.latitude);
console.log(
`设置相机对准塌陷区域中心: 经度 ${centerLon.toFixed(
6
)}, 纬度 ${centerLat.toFixed(6)}`
);
camera.setView({
...DEFAULT_CAMERA_VIEW,
lon: centerLon,
lat: centerLat,
});
return true;
};
// 步骤1: 提前获取塌陷区域中心点(不依赖地形加载)
// 如果成功获取,先设置相机对准该区域,让用户看到目标区域
const collapseCenter = getCollapseCenter();
let hasCameraTarget = focusCameraOnCartesian(collapseCenter);
if (!hasCameraTarget) {
console.warn("无法获取塌陷区域中心,使用默认相机位置");
camera.setView(DEFAULT_CAMERA_VIEW);
}
// 步骤2: 加载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模型已完全就绪");
} else {
console.warn("[index.vue] 3D模型加载返回 null");
}
} catch (error) {
console.error("[index.vue] 3D模型加载失败:", error);
}
// 步骤3: 初始化地图标记(单兵、设备、应急基地等)
// 此时3D Tiles已加载完成可以安全添加标记
try {
console.log("[index.vue] 开始初始化地图标记...");
const sampledCollapseCenter = await initializeMarkers(viewer, {
useSampledHeights: true, // 使用采样高度,确保标记位置准确
heightOffset: 10, // 标记相对地面10米
});
// 如果之前没有设置相机(配置数据缺失),现在再次尝试
if (!hasCameraTarget && sampledCollapseCenter) {
hasCameraTarget = focusCameraOnCartesian(sampledCollapseCenter);
}
console.log("[index.vue] 地图标记初始化完成");
// camera.setView({
// ...DEFAULT_CAMERA_VIEW,
// })
} catch (error) {
console.error("[index.vue] 地图标记初始化失败:", error);
// 即使标记初始化失败,也要确保相机位置正确
if (!hasCameraTarget) {
console.warn("[index.vue] 标记初始化失败且无相机目标,使用默认位置");
// camera.setView(DEFAULT_CAMERA_VIEW)
}
}
});
});
// 根据经纬度加载养护站数据 /disaster/matchEmergencyResources
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;
}
};
onMounted(async () => {
// 加载应急资源数据(使用默认灾害点坐标)
// await loadEmergencyResources(108.011506, 30.175827);
const response = await loadEmergencyResources(108.011506, 30.175827);
});
/**
* 处理地图工具变化事件
* 目前主要处理"模型对比"工具的切换
*
* @param {Object} payload - 工具变化事件载荷
* @param {string} payload.tool - 工具标识
* @param {boolean} payload.active - 工具是否激活
*/
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,
});
}
}
// 其他工具的处理可以在这里扩展
// if (tool === 'measure') { ... }
};
// 弹窗状态
const showPersonnelDetail = ref(false);
const showCenterDetail = 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,
});
/**
* 地图 Tooltip 状态管理
* 用于显示地图标记点的轻量级信息提示框
*/
const mapTooltip = ref({
visible: false,
x: 0,
y: 0,
title: "",
icon: "",
zIndex: 20, // 高于地图控件层,低于全屏弹窗
data: null, // 业务数据,用于内容插槽渲染
});
// 返回驾驶舱
const handleBack = () => {
console.log("返回驾驶舱");
// 实际实现:路由跳转
// router.push('/cockpit')
};
// 处理人员联动
const handlePersonnelLink = (personnel) => {
console.log("联动人员:", personnel);
// 实际实现:使用 mapStore 的 camera service 飞行到人员位置
// const { camera } = mapStore.services()
// camera.flyTo({ destination: [lon, lat, height] })
showPersonnelDetail.value = false;
};
/**
* 关闭地图 Tooltip
* 统一的关闭入口,便于后续扩展埋点或联动逻辑
*/
const handleMapTooltipClose = () => {
mapTooltip.value.visible = false;
};
/**
* 在指定屏幕坐标显示地图 Tooltip
*
* 使用场景:
* - 当用户点击地图上的标记点时调用此方法
* - 调用方需要将地图实体的经纬度转换为屏幕坐标
*
* @typedef {Object} MapTooltipField
* @property {string} label - 字段标签
* @property {string} value - 字段值
*
* @typedef {Object} MapTooltipData
* @property {MapTooltipField[]} fields - 字段列表
*
* @param {Object} options - Tooltip 配置选项
* @param {number} options.x - 屏幕 X 坐标(像素),相对于地图容器左上角
* @param {number} options.y - 屏幕 Y 坐标(像素),相对于地图容器左上角
* @param {string} [options.title=''] - Tooltip 标题文本
* @param {string} [options.icon=''] - 标题左侧图标的图片路径
* @param {MapTooltipData} [options.data=null] - 业务数据,用于内容插槽渲染
*
* @example
* // 当点击地图实体时
* const screenPos = mapStore.worldToScreen(entity.position)
* showMapTooltip({
* x: screenPos.x,
* y: screenPos.y,
* title: '应急中心',
* icon: emergencyCenterIcon,
* data: {
* fields: [
* { label: '名称', value: '忠县应急中心' },
* { label: '行政等级', value: '国道' },
* { label: '隶属单位', value: '交通公路部门' }
* ]
* }
* })
*/
const showMapTooltip = ({ x, y, title = "", icon = "", data = null }) => {
const state = mapTooltip.value;
state.visible = true;
state.x = x;
state.y = y;
state.title = title;
state.icon = icon;
state.data = data;
// zIndex 保持不变,无需重新赋值
};
// TODO: 实现地图实体点击事件监听
// 当用户点击地图上的标记点时,显示 Tooltip 或详情弹窗
//
// 集成示例(需要先在文件顶部导入图标):
// import personnelIcon from './assets/images/personnel-icon.png'
// import centerIcon from './assets/images/center-icon.png'
//
// mapStore.onReady(() => {
// const { query } = mapStore.services()
//
// // 监听实体点击事件
// query.onEntityClick((entity) => {
// // 1. 将实体位置转换为屏幕坐标
// // 注意:具体 API 取决于你使用的地图引擎Cesium/Mapbox/etc.
// const screenPos = mapStore.worldToScreen(entity.position)
//
// // 2. 显示 Tooltip 展示基本信息
// if (entity.type === 'personnel') {
// showMapTooltip({
// x: screenPos.x,
// y: screenPos.y,
// title: '应急人员',
// icon: personnelIcon,
// data: {
// fields: [
// { label: '姓名', value: entity.properties.name },
// { label: '部门', value: entity.properties.department },
// { label: '距离', value: `${entity.properties.distance}公里` }
// ]
// }
// })
// } else if (entity.type === 'center') {
// showMapTooltip({
// x: screenPos.x,
// y: screenPos.y,
// title: '应急中心',
// icon: centerIcon,
// data: {
// fields: [
// { label: '名称', value: entity.properties.name },
// { label: '行政等级', value: entity.properties.adminLevel },
// { label: '隶属单位', value: entity.properties.department }
// ]
// }
// })
// }
//
// // 3. 如果需要打开详情弹窗,可以在 Tooltip 中添加按钮
// // 或者直接在这里同时打开详情弹窗(根据业务需求)
// // selectedPersonnel.value = entity.properties
// // showPersonnelDetail.value = true
// })
// })
</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(
121 / 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;
visibility: hidden;
}
}
// 双地图对比模式
&.is-compare-mode {
.situational-awareness__left-map {
width: 50%;
visibility: visible;
transition: width 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;
}
// 浮动面板层 - grid 与 pointer-events 结合保证中间透明
&__panels-layer {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-columns: var(--sa-left-width) 1fr var(--sa-right-width);
grid-auto-rows: 1fr;
gap: var(--sa-gap); // 列之间的间距
height: 100%;
padding-top: var(--sa-header-height); // 预留 Header 高度
pointer-events: none; // 容器不拦截事件,让中间区域透明
}
// 左右面板列 - 浮动卡片样式
&__panel-column {
display: flex;
flex-direction: column;
gap: var(--sa-gap); // 列内子面板之间的间距
min-width: 0; // 防止在窄容器中溢出
min-height: 0; // 允许 flex 子元素收缩并启用滚动
pointer-events: auto; // 恢复面板的交互能力
}
// 中间占位区域 - 透明且不可交互,点击穿透到地图
&__center-spacer {
pointer-events: none;
}
// 地图控件层 - 高于遮罩和面板,用于放置地图控制工具
&__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; // 容器不拦截事件,点击穿透到地图
}
}
// 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;
}
}
// 窄容器嵌入的紧凑布局(<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>