Procházet zdrojové kódy

[Feat 0000]地图管理CAD功能初版

wangkeyi před 1 měsícem
rodič
revize
8068ea3129

+ 1 - 0
src/assets/icons/location-icon.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774321691566" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9546" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 0C294.208 0 117.034667 177.152 117.056 394.922667c0 80.896 24.298667 158.677333 69.781333 224.149333 2.282667 3.925333 4.586667 7.722667 7.296 11.413333l288.277333 379.989333C490.24 1019.2 500.757333 1024 512.021333 1024c11.114667 0 21.696-4.842667 30.848-15.104l286.954667-378.474667c2.837333-3.754667 5.248-7.872 6.570667-10.282667 46.144-66.389333 70.570667-144.256 70.570667-225.173333C906.965333 177.152 729.792 0 512 0zM512 536.170667c-77.781333 0-141.077333-63.296-141.077333-141.098667 0-77.781333 63.296-141.056 141.077333-141.056 77.781333 0 141.077333 63.296 141.077333 141.056C653.077333 472.874667 589.781333 536.170667 512 536.170667z" p-id="9547" fill="#1296db" data-spm-anchor-id="a313x.search_index.0.i6.4f2a3a81EPHebN" class=""></path></svg>

binární
src/assets/icons/location-icon3D.png


+ 447 - 42
src/views/system/cadFile/app.ts

@@ -1,68 +1,73 @@
-import vjmap, { Map } from 'vjmap';
+import vjmap, { Map, GeoBounds, GeoProjection, Service } from 'vjmap';
 import { env } from './env';
 import { App, MapThreeLayer } from 'vjmap3d';
 import 'vjmap/dist/vjmap.min.css';
 import { useAppStore } from '/@/store/modules/app';
+import { message, Modal } from 'ant-design-vue';
+import { updatateGoaf } from './cad.api';
+import { createApp, h } from 'vue';
+import GoafPopup from './components/goafPopup.vue';
 
+// ===================== 全局状态 =====================
+let map: Map | null = null;
+let svc: Service | null = null;
+let prj: GeoProjection | null = null;
+let allMarkers: any[] = [];
+let sensorId = 1;
+let currentPopup: any = null;
+
+// ===================== 地图初始化部分 =====================
+// 初始化地图
 const createMapApp = async () => {
   const appStore = useAppStore();
   const containerId = 'map3dContainer';
-  // @ts-ignore
   const containerDiv = document.getElementById(containerId);
-  //   document.getElementById(containerId).style.background = env.background || (env.darkTheme ? '#2c3e50' : 'rgb(200, 211, 220)');
-  containerDiv.style.background = 'transparent';
-  //   debugger;
-  containerDiv.style.width = containerDiv?.parentElement?.clientWidth / appStore.widthScale + 'px';
-  containerDiv.style.height = containerDiv?.parentElement?.clientHeight / appStore.heightScale + 'px';
-
-  const svc = new vjmap.Service(env.serviceUrl, env.accessToken);
-  if (env.workspace) {
-    // 如果有工作区,切换至相对应的工作区
-    svc.switchWorkspace(env.workspace);
+  
+  if (!containerDiv) {
+    message.error("地图容器不存在!");
+    return null;
   }
-  // 打开地图
+
+  containerDiv.style.background = 'transparent';
+  containerDiv.style.width = `${containerDiv?.parentElement?.clientWidth / appStore.widthScale}px`;
+  containerDiv.style.height = `${containerDiv?.parentElement?.clientHeight / appStore.heightScale}px`;
+
+  svc = new vjmap.Service(env.serviceUrl, env.accessToken);
+  if (env.workspace) svc.switchWorkspace(env.workspace);
+
   const style = env.darkTheme ? vjmap.openMapDarkStyle() : vjmap.openMapLightStyle();
-  style.clipbounds = Math.pow(2, 6); // 只传值,不传范围,表示之前的范围放大多少倍
-  //   const httpDwgUrl = 'https://vjmap.com/static/assets/data/gym.dwg'; // 长传到服务器上的dwg文件
-  const httpDwgUrl = './test.dwg';
+  style.clipbounds = Math.pow(2, 6);
+  const httpDwgUrl = 'https://vjmap.com/static/assets/data/gym.dwg';
+  
   const res = await svc.openMap({
-    // mapid: env.mapId, // 地图ID,上传文件后获得的mapid
     version: env.version,
-    fileid: httpDwgUrl, // 使用文件地址
-    // @ts-ignore
-    mapopenway: env.mapopenway || vjmap.MapOpenWay.GeomRender, // 以几何数据渲染方式打开
+    fileid: httpDwgUrl,
+    mapopenway: env.mapopenway || vjmap.MapOpenWay.GeomRender,
     style,
   });
+
   if (res.error) {
-    // 如果打开出错
+    message.error(`地图加载失败:${res.error}`);
     console.error(res.error);
   }
-  // 获取地图范围
-  const mapExtent = vjmap.GeoBounds.fromString(res.bounds);
-  // 根据地图范围建立几何投影坐标系
-  const prj = new vjmap.GeoProjection(mapExtent);
-
-  // 地图对象
-  const map = new vjmap.Map({
-    container: containerId, // DIV容器ID
-    style: svc.rasterStyle(), // 样式,这里是栅格样式
-    center: prj.toLngLat(mapExtent.center()), // 设置地图中心点
-    zoom: 7, // 设置地图缩放级别
+
+  const mapExtent = GeoBounds.fromString(res.bounds);
+  prj = new GeoProjection(mapExtent);
+
+  map = new vjmap.Map({
+    container: containerId,
+    style: svc.rasterStyle(),
+    center: prj.toLngLat(mapExtent.center()),
+    zoom: 7,
     pitch: 0,
-    antialias: true, // 反锯齿
-    renderWorldCopies: false, // 不显示多屏地图
+    antialias: true,
+    renderWorldCopies: false,
   });
 
-  // 关联服务对象和投影对象
-  //   map.attach(svc, prj);
   await map.onLoad();
 
-  // 创建3d图层
   const mapLayer = new MapThreeLayer(map, {
-    stat: {
-      show: false,
-      left: '0',
-    },
+    stat: { show: false, left: '0' },
     scene: {
       showAxesHelper: false,
       axesHelperSize: 1,
@@ -77,11 +82,411 @@ const createMapApp = async () => {
   });
   const app: App = mapLayer.app;
   map.addLayer(new vjmap.ThreeLayer({ context: mapLayer as any }));
+  map.doubleClickZoom.disable();
 
-  map.doubleClickZoom.disable(); // 禁止双击缩放
   return app;
 };
+
+// 对外导出地图初始化方法
 export const initMap2d = async () => {
   const app = await createMapApp();
   return app;
 };
+
+// ===================== 地图操作处理部分 =====================
+// 拾取点位
+const pickPoint = async (markerOptions: any) => {
+  if (!map) return null;
+  let marker: any = null;
+  const actionPoint = await vjmap.Draw.actionDrawPoint(map, {
+    updatecoordinate: (e: any) => {
+      if (!e.lnglat) return;
+      if (!marker) {
+        marker = new vjmap.createMarker(markerOptions);
+        marker.setLngLat(e.lnglat);
+        marker.addTo(map);
+      } else {
+        marker.setLngLat(e.lnglat);
+      }
+    },
+    contextMenu: (e: any) => {
+      new vjmap.ContextMenu({
+        event: e.event.originalEvent,
+        theme: "dark",
+        width: "250px",
+        items: [
+          {
+            label: '取消',
+            onClick: () => map?.fire("keyup", { keyCode: 27 })
+          }
+        ]
+      });
+    }
+  });
+  if (actionPoint.cancel) {
+    marker?.remove();
+    return null;
+  }
+  return marker;
+};
+
+/**
+ * 判断坐标是否有效
+ */
+export const getGoafIsPositioned = (goafItem: any): boolean => {
+  const hasValidX = isValidSingleCoord(goafItem.xcoordinate);
+  const hasValidY = isValidSingleCoord(goafItem.ycoordinate);
+  return hasValidX && hasValidY;
+};
+
+// 获取所有标记状态列表
+export const getMarkerStatusList = () => {
+  if (!map) return [];
+  return allMarkers.map(marker => ({
+    sensorId: marker.data?.sensorId,
+    id: marker.data?.id,
+    data: marker.data,
+    lnglat: marker.getLngLat(),
+    position: map?.fromLngLat(marker.getLngLat())
+  }));
+};
+
+// 移除指定传感器标记
+export const removePosition = async (sensorId: number | string, id: string | number) => {
+  if (!map) {
+    message.error("地图未初始化完成!");
+    return false;
+  }
+
+  const targetIndex = allMarkers.findIndex(marker => marker.data?.sensorId === sensorId || marker.data?.id === id);
+  if (targetIndex === -1) {
+    message.warning("未找到该传感器标记!");
+    return false;
+  }
+
+  try {
+    await updatateGoaf({
+      id: id,
+      xcoordinate: "",
+      ycoordinate: ""
+    });
+    console.info("移除布点成功!");
+  } catch (apiError) {
+    message.error(`同步移除信息失败:${(apiError as Error).message}`);
+    console.error("移除接口调用失败:", apiError);
+  }
+
+  allMarkers[targetIndex].remove();
+  allMarkers.splice(targetIndex, 1);
+  syncLocalStorage();
+  
+  return true;
+};
+
+// 布点方法
+export const bindPosition = async (props?: any) => {
+  if (!map || !svc) {
+    message.error("地图未初始化完成,请先加载地图!");
+    return null;
+  }
+
+  map.fire("keyup", { keyCode: 27 });
+
+  const el = createMarkerElement({ 
+    alarmLevel: props?.alarmLevel, 
+    devicePos: props?.devicePos 
+  }, false);
+  const markerOptions = { 
+    element: el,
+    anchor: 'bottom',
+    offset: [0, 0]
+  };
+
+  let marker: any = null;
+  if (!props || !props.position) {
+    marker = await pickPoint(markerOptions);
+    if (!marker) return null;
+
+    const confirmResult = await new Promise<boolean>((resolve) => {
+      Modal.confirm({
+        title: '保存布点',
+        content: '是否确认保存该布点?',
+        okText: '确定',
+        cancelText: '取消',
+        maskClosable: false,
+        centered: true,
+        onOk: () => resolve(true),
+        onCancel: () => resolve(false)
+      });
+    });
+
+    if (!confirmResult) {
+      marker.remove();
+      return null;
+    }
+
+    const lnglat = marker.getLngLat();
+    const id = props?.id;
+    
+    if (!id) {
+      marker.remove();
+      message.error("缺少密闭ID,无法同步布点信息到服务器");
+      throw new Error("缺少密闭ID,无法同步布点信息到服务器");
+    }
+
+    try {
+      await updatateGoaf({
+        id: id,
+        xcoordinate: lnglat.lng.toString(),
+        ycoordinate: lnglat.lat.toString()
+      });
+    } catch (apiError) {
+      marker.remove();
+      message.error(`同步布点信息失败:${(apiError as Error).message}`);
+      throw new Error(`同步布点信息失败:${(apiError as Error).message}`);
+    }
+
+    const currentLngLat = marker.getLngLat();
+    marker.remove();
+    
+    const finalEl = createMarkerElement({ 
+      alarmLevel: props?.alarmLevel, 
+      devicePos: props?.devicePos 
+    }, true);
+    
+    marker = vjmap.createMarker({ 
+      element: finalEl,
+      anchor: 'bottom',
+      offset: [0, 0]
+    });
+    marker.setLngLat(currentLngLat);
+    marker.addTo(map);
+    marker.data = { 
+      sensorId: sensorId++,
+      id: id,
+      ...(props || {})
+    };
+  } else {
+    if (!Array.isArray(props.position)) {
+      throw new Error("预设坐标position格式错误,必须是[x, y]数组");
+    }
+    const lngLat = map.toLngLat(props.position);
+    if (!lngLat) {
+      throw new Error("转换坐标失败,无效的position");
+    }
+    
+    marker = vjmap.createMarker(markerOptions);
+    marker.setLngLat(lngLat);
+    marker.data = { 
+      ...props.data,
+      sensorId: props.data?.sensorId || sensorId++,
+      id: props.data?.id || props.id,
+      ...(props || {})
+    };
+    marker.addTo(map);
+  }
+
+  bindMarkerEvents(marker, marker.data?.id);
+  allMarkers.push(marker);
+  syncLocalStorage();
+
+  return {
+    sensorId: marker.data.sensorId,
+    id: marker.data.id,
+    lnglat: marker.getLngLat(),
+    xcoordinate: marker.getLngLat().lng.toString(),
+    ycoordinate: marker.getLngLat().lat.toString()
+  };
+};
+
+// 渲染密闭列表标记
+export const renderGoafMarkers = async (goafList: any[]) => {
+  if (!map || !prj) {
+    message.error("地图或投影坐标系未初始化完成!");
+    return;
+  }
+
+  allMarkers.forEach(marker => marker.remove());
+  allMarkers = [];
+  sensorId = 1;
+
+  const validGoafList = goafList.filter(item => getGoafIsPositioned(item));
+  if (validGoafList.length === 0) {
+    console.info("当前密闭列表无有效布点坐标,暂无标记可渲染");
+    return;
+  }
+
+  for (const goafItem of validGoafList) {
+    try {
+      const lnglat = new vjmap.LngLat(Number(goafItem.xcoordinate), Number(goafItem.ycoordinate));
+      const el = createMarkerElement(goafItem, true);
+      
+      const marker = vjmap.createMarker({ 
+        element: el, 
+        anchor: 'bottom',
+        offset: [0, 0]
+      });
+      marker.setLngLat(lnglat);
+      marker.data = {
+        sensorId: sensorId++,
+        id: goafItem.id,
+        ...goafItem
+      };
+      marker.addTo(map);
+
+      bindMarkerEvents(marker, goafItem.id);
+      allMarkers.push(marker);
+    } catch (err) {
+      console.error(`渲染密闭${goafItem.id}标记失败:`, err);
+    }
+  }
+};
+
+// ===================== 内部辅助函数 =====================
+/**
+ * 校验单个坐标是否有效
+ */
+const isValidSingleCoord = (coord: any): boolean => {
+  return coord && coord.trim() !== "" && !isNaN(Number(coord));
+};
+
+/**
+ * 同步标记数据到本地缓存
+ */
+const syncLocalStorage = (): void => {
+  if (!map) return;
+  const markerData = allMarkers.map((m) => ({
+    position: map?.fromLngLat(m.getLngLat()),
+    data: m.data
+  }));
+  localStorage.setItem("marker_sensor", JSON.stringify(markerData));
+};
+
+/**
+ * 关闭并清理弹窗
+ */
+const closeAndCleanPopup = (): void => {
+  if (currentPopup) {
+    currentPopup.remove();
+    currentPopup = null;
+  }
+};
+
+/**
+ * 绑定标记的拖拽和点击事件
+ */
+const bindMarkerEvents = (marker: any, id: string | number): void => {
+  marker.setDraggable(false);
+  let isDragging = false;
+  
+  marker.on("dragstart", () => { isDragging = false; });
+  marker.on("dragend", () => {
+    setTimeout(() => { isDragging = false; }, 150);
+    const lnglat = marker.getLngLat();
+    if (id) {
+      updatateGoaf({
+        id,
+        xcoordinate: lnglat.lng.toString(),
+        ycoordinate: lnglat.lat.toString()
+      }).catch((err: Error) => {
+        message.error(`拖拽后同步坐标失败:${err.message}`);
+      });
+    }
+  });
+
+  marker.getElement().addEventListener('click', () => {
+    if (!isDragging) {
+      showMarkerPopup(marker);
+    }
+  });
+};
+/**
+ * 创建传感器标记 DOM 元素
+ */
+const createMarkerElement = (goafItem: any, showInfo: boolean = false): HTMLDivElement => {
+  const customImage = `/src/assets/icons/location-icon.svg`;
+  const customImage3D = `/src/assets/icons/location-icon3D.png`;
+  const el = document.createElement('div');
+  el.className = 'marker';
+  el.style.cssText = `
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    cursor: move;
+  `;
+
+  if (showInfo) {
+    const alarmLevelDiv = document.createElement('div');
+    alarmLevelDiv.style.cssText = `
+      font-size: 14px;
+      color: #333;
+      margin-bottom: 2px;
+      white-space: nowrap;
+      font-weight: 600;
+    `;
+    alarmLevelDiv.textContent = goafItem.alarmLevel;
+    el.appendChild(alarmLevelDiv);
+  }
+
+  const iconDiv = document.createElement('div');
+  iconDiv.style.cssText = `
+    background-image: url("${showInfo ? customImage3D : customImage}");
+    width: 40px;
+    height: 45px;
+    background-size: 100% 100%;
+    background-position: center;
+    background-repeat: no-repeat;
+  `;
+  el.appendChild(iconDiv);
+
+  if (showInfo) {
+    const devicePosDiv = document.createElement('div');
+    devicePosDiv.style.cssText = `
+      position: absolute;
+      font-size: 14px;
+      color: #333;
+      margin-top: 2px;
+      white-space: nowrap;
+      max-width: 120px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      bottom: -20px;
+    `;
+    devicePosDiv.textContent = goafItem.devicePos;
+    el.appendChild(devicePosDiv);
+  }
+
+  return el;
+};
+
+// 显示标记信息弹窗
+const showMarkerPopup = (marker: any) => {
+  if (!map) return;
+  
+  closeAndCleanPopup();
+
+  const lnglat = marker.getLngLat();
+  const data = marker.data || {};
+
+  const tempContainer = document.createElement('div');
+  const popupApp = createApp({
+    render: () => h(GoafPopup, { data })
+  });
+  popupApp.mount(tempContainer);
+
+  currentPopup = new vjmap.Popup({
+    closeButton: true,
+    closeOnClick: false,
+    maxWidth: '800px',
+    offset: [0, 0],
+  })
+    .setLngLat(lnglat)
+    .setDOMContent(tempContainer)
+    .addTo(map);
+
+  currentPopup.on('close', () => {
+    popupApp.unmount();
+    tempContainer.remove();
+    currentPopup = null;
+  });
+};

+ 20 - 0
src/views/system/cadFile/cad.api.ts

@@ -0,0 +1,20 @@
+import { defHttp } from '/@/utils/http/axios';
+
+enum Api {
+  getGoafList = '/province/device/getGoafList',
+  updatateGoaf = '/province/device/updateGoaf',
+  getGoafData = '/province/device/getGoafData',
+}
+//查询密闭列表
+export function getGoafList(params: any) {
+  return defHttp.post({ url: Api.getGoafList, params }, { joinParamsToUrl: true });
+};
+
+// 更新密闭
+export function updatateGoaf(params: any) {
+  return defHttp.post({ url: Api.updatateGoaf, params });
+};
+// 查询密闭监测数据
+export function getGoafData(params: any) {
+  return defHttp.post({ url: Api.getGoafData, params }, { joinParamsToUrl: true });
+}

+ 51 - 0
src/views/system/cadFile/cad.data.ts

@@ -0,0 +1,51 @@
+// 模拟传感器数据
+export const sensorMockData = [
+  {
+    goafId: 1,
+    devicePos: "密闭A",
+    type: "传感器1",
+    createTime: "2026-01-10 14:20:30"
+  },
+  {
+    goafId: 2,
+    devicePos: "密闭B",
+    type: "传感器2",
+    createTime: "2026-01-11 09:15:22"
+  },
+  {
+    goafId: 3,
+    devicePos: "密闭C",
+    type: "传感器3",
+    createTime: "2026-01-12 16:08:15"
+  },
+  {
+    goafId: 4,
+    devicePos: "密闭D",
+    type: "传感器1",
+    createTime: "2026-01-13 11:30:45"
+  },
+  {
+    goafId: 5,
+    devicePos: "密闭E",
+    type: "传感器2",
+    createTime: "2026-01-14 15:40:10"
+  },
+  {
+    goafId: 6,
+    devicePos: "密闭F",
+    type: "传感器3",
+    createTime: "2026-01-15 10:05:50"
+  },
+  {
+    goafId: 7,
+    devicePos: "密闭G",
+    type: "传感器1",
+    createTime: "2026-01-16 08:25:33"
+  },
+  {
+    goafId: 8,
+    devicePos: "密闭H",
+    type: "传感器2",
+    createTime: "2026-01-17 13:18:27"
+  }
+];

+ 373 - 0
src/views/system/cadFile/components/SideDrawer.vue

@@ -0,0 +1,373 @@
+<template>
+  <!-- 自定义左侧抽屉-->
+  <div class="custom-drawer" :class="{ 'custom-drawer-open': visible }">
+    <!-- 抽屉头部:标题 + 关闭按钮 -->
+    <div class="custom-drawer-header">
+      <span class="custom-drawer-title">密闭管理</span>
+      <div @click="handleClose" class="close-btn">
+        X
+      </div>
+    </div>
+
+    <!-- 抽屉内容区 -->
+    <div class="custom-drawer-body" v-if="visible">
+      <div class="drawer-content">
+        <!-- 搜索区域 -->
+        <div class="search-section">
+          <MineCascader
+            v-model:value="selectedMineId"
+            placeholder="请选择煤矿"
+            style="width: 60%; margin-right: 8px"
+            :syncToStore="false"
+            :initFromStore="false"
+            :showSearch="true"
+            :allowClear="true"
+            @change="handleMineChange"
+          />
+          <a-button type="primary" @click="handleGoafSearch" style="margin-right: 8px">
+            搜索
+          </a-button>
+          <a-button @click="handleRefresh">
+            刷新
+          </a-button>
+        </div>
+        <div class="search-section">
+          <a-input
+            v-model:value="searchValue"
+            placeholder="密闭位置"
+            style="width: 60%; margin-right: 8px"
+          />
+        </div>
+
+        <!-- 传感器数据表格 -->
+        <div class="table-section">
+          <a-table
+            :columns="columns"
+            :data-source="filteredDataWithStatus"
+            :pagination="pagination"
+            :row-key="(record) => record.id"
+            @change="handleTableChange"
+            :scroll="{ y: 'calc(100vh - 320px)' }"
+            size="middle"
+          >
+            <!-- 操作列自定义渲染 -->
+            <template #operation="{ record }">
+              <a-space>
+                <a-button 
+                  v-if="!record.isPlaced" 
+                  size="small" 
+                  @click="handleDeploy(record)"
+                >
+                  布点
+                </a-button>
+                <a-button 
+                  v-else 
+                  size="small" 
+                  danger 
+                  @click="handleRemove(record)"
+                >
+                  移除
+                </a-button>
+              </a-space>
+            </template>
+          </a-table>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, watch } from "vue";
+import { message } from "ant-design-vue";
+import { bindPosition, removePosition, getMarkerStatusList, renderGoafMarkers } from "../app";
+import MineCascader from '@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
+import { getGoafList } from '../cad.api';
+
+interface Props {
+  visible: boolean;
+}
+const props = defineProps<Props>();
+const emit = defineEmits<{
+  (e: "update:visible", value: boolean): void;
+}>();
+
+// 响应式数据
+const searchValue = ref("");
+const pagination = ref({
+  current: 1,
+  pageSize: 10,
+  total: 0 // 初始化为0,由接口数据决定
+});
+const selectedMineId = ref("100008"); //这里暂时写死id,目前逻辑有问题
+// 新增:存储接口返回的密闭数据
+const goafList = ref<any[]>([]);
+
+// 表格列配置 - 修复字段拼写错误(devicePos → devicePos)
+const columns = [
+  {
+    title: "操作",
+    key: "operation",
+    slots: { customRender: "operation" },
+    width: 80,
+    fixed: 'left'
+  },
+  {
+    title: "密闭位置",
+    dataIndex: "devicePos", // 修正拼写错误
+    key: "devicePos",
+    ellipsis: true,
+    tooltip: true
+  },
+];
+
+// 表格数据过滤逻辑 - 核心修改:基于接口坐标判断isPlaced
+const filteredDataWithStatus = computed(() => {
+  // 从接口返回的goafList过滤
+  let filteredData = goafList.value.filter(item => {
+    // 密闭位置搜索匹配(兼容空值)
+    const isSearchMatch = searchValue.value 
+      ? (item.devicePos || '').includes(searchValue.value) 
+      : true;
+    return isSearchMatch;
+  });
+
+  // 关联标记状态 + 核心:优先判断接口返回的坐标
+  const placedMarkers = getMarkerStatusList() || [];
+  const dataWithStatus = filteredData.map(item => {
+    // 判断接口坐标是否有效(非null/""且为数字)
+    const hasValidX = item.xcoordinate && !isNaN(Number(item.xcoordinate));
+    const hasValidY = item.ycoordinate && !isNaN(Number(item.ycoordinate));
+    // 兜底:本地标记是否存在(防止接口未同步的临时状态)
+    const matchedMarker = placedMarkers.find(marker => marker.data?.id === item.id);
+    
+    return {
+      ...item,
+      // 优先用接口坐标判断,无接口坐标时用本地标记
+      isPlaced: hasValidX && hasValidY, 
+      sensorId: matchedMarker?.sensorId || item.id,
+    };
+  });
+
+  // 分页逻辑
+  const start = (pagination.value.current - 1) * pagination.value.pageSize;
+  const end = start + pagination.value.pageSize;
+  pagination.value.total = filteredData.length;
+  return dataWithStatus.slice(start, end);
+});
+
+// 关闭抽屉
+const handleClose = () => emit("update:visible", false);
+
+// 搜索方法
+const handleGoafSearch = async () => {
+  // 校验:必须选择煤矿
+  if (!selectedMineId.value) {
+    message.warning("请先选择煤矿!");
+    return;
+  }
+
+  try {
+    // 调用接口
+    const res = await getGoafList({
+      order: "desc",
+      mineCodeList: selectedMineId.value,
+    });
+    goafList.value = res || [];
+    pagination.value.current = 1; // 搜索后重置页码到第一页
+    await renderGoafMarkers(goafList.value); // 渲染标记
+
+  } catch (error) {
+    goafList.value = [];
+    message.error(`查询失败:${(error as Error).message}`);
+    console.error("接口请求失败:", error);
+  }
+};
+
+// 刷新方法 - 优化:强制重新请求接口
+const handleRefresh = async () => {
+  searchValue.value = "";
+  pagination.value.current = 1;
+  pagination.value = { ...pagination.value }; // 触发响应式更新
+  
+  // 如果已选择煤矿,刷新时重新请求接口(保证数据最新)
+  if (selectedMineId.value) {
+    await handleGoafSearch();
+  } else {
+    goafList.value = []; // 未选煤矿则清空表格
+  }
+};
+
+// 表格分页变化
+const handleTableChange = (pag: any) => {
+  pagination.value = { ...pagination.value, ...pag };
+};
+
+// 布点方法 - 优化:布点后主动刷新接口数据
+const handleDeploy = async (record: any) => {
+  try {
+    const marker = await bindPosition({ 
+      id: record.id,
+      devicePos: record.devicePos
+    });
+    if (!marker) {
+      message.warning("布点操作已取消");
+      return;
+    }
+    // 布点成功后刷新接口数据,保证按钮状态同步
+    await handleRefresh();
+  } catch (error) {
+    message.error(`布点失败:${(error as Error).message}`);
+    console.error("布点失败:", error);
+  }
+};
+
+// 移除后主动刷新接口数据
+const handleRemove = async (record: any) => {
+  try {
+    const isSuccess = await removePosition(record.sensorId, record.id);
+    if (isSuccess) {
+      // 移除成功后刷新接口数据,保证按钮状态同步
+      await handleRefresh();
+    }
+  } catch (error) {
+    message.error(`移除失败:${(error as Error).message}`);
+    console.error("移除失败:", error);
+  }
+};
+
+// 煤矿选择变化
+const handleMineChange = (mineId: string) => {
+  selectedMineId.value = mineId;
+};
+
+// 监听抽屉显隐:打开时刷新数据
+watch(() => props.visible, async (isVisible) => {
+  if (isVisible) await handleRefresh(); // 异步数据加载
+}, { immediate: true });
+
+// 初始化:分页总数同步为接口数据量
+onMounted(() => {
+  pagination.value.total = goafList.value.length;
+});
+</script>
+
+<style scoped>
+/* 自定义抽屉核心容器 */
+.custom-drawer {
+  position: fixed;
+  top: 50px;
+  left: 0;
+  z-index: 999;
+  width: 450px;
+  height: calc(100vh - 50px);
+  background: #ffffff;
+  box-shadow: 2px 0 12px rgba(0, 0, 0, 0.1);
+  transform: translateX(-100%);
+  transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+/* 抽屉打开状态 - 滑入动画 */
+.custom-drawer-open {
+  transform: translateX(0);
+}
+
+/* 抽屉头部 */
+.custom-drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: #ffffff;
+  background-color: #2d4f82;
+  padding: 14px 20px;
+  flex-shrink: 0;
+}
+
+.custom-drawer-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #ffffff;
+  line-height: 1.5;
+}
+
+.close-btn {
+    font-size: 20px;
+    padding: 0;
+    height: 20px;
+    width: 20px;
+    margin-top: -10px;
+    cursor: pointer;
+}
+
+
+/* 抽屉内容区 */
+.custom-drawer-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0;
+}
+
+.drawer-content {
+  padding: 16px 20px;
+}
+
+/* 搜索区域布局 */
+.search-section {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+  width: 100%;
+}
+
+/* 下拉选择区域间距 */
+.select-section {
+  margin-bottom: 16px;
+}
+
+/* 表格区域宽度适配 */
+.table-section {
+  width: 100%;
+}
+
+/* 表格样式 */
+:deep(.ant-table) {
+  font-size: 14px;
+}
+
+:deep(.ant-table-thead > tr > th) {
+  background: #fafafa;
+  font-weight: 600;
+  color: #000;
+  padding: 10px 12px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+:deep(.ant-table-tbody > tr > td) {
+    color: #000 !important;
+    font-family: Source Han Sans SC !important;
+    font-size: 16px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+}
+:deep(.ant-table-tbody > .ant-table-row) {
+  &:hover {
+    background-color: #a4d3ee !important;
+  }
+}
+:deep(.ant-table-tbody > .ant-table-row > td) {
+  background-color: unset !important;
+  &:hover {
+    background-color: unset !important;
+  }
+}
+
+
+/* 分页样式 */
+:deep(.ant-pagination) {
+  margin: 16px 0 0 0;
+  text-align: right;
+}
+</style>

+ 194 - 0
src/views/system/cadFile/components/goafPopup.vue

@@ -0,0 +1,194 @@
+<template>
+  <div class="goaf-popup-container">
+    <!-- 加载状态 -->
+    <div v-if="loading" class="loading">加载中...</div>
+
+    <!-- 固定监测数据展示 -->
+    <div v-else class="record-list">
+      <div class="record-item">
+        <span class="label">CO(ppm)</span>
+        <span class="value">{{ monitorData.coVal || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">CO₂(%)</span>
+        <span class="value">{{ monitorData.co2Val || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">CH₄(%)</span>
+        <span class="value">{{ monitorData.ch4Val || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">C₂H₂(ppm)</span>
+        <span class="value">{{ monitorData.c2h2Val || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">C₂H₄(ppm)</span>
+        <span class="value">{{ monitorData.c2h4Val || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">O₂(%)</span>
+        <span class="value">{{ monitorData.o2Val || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">温度(℃)</span>
+        <span class="value">{{ monitorData.temperature || '--' }}</span>
+      </div>
+      <div class="record-item">
+        <span class="label">压差(Pa)</span>
+        <span class="value">{{ monitorData.sourcePressure || '--' }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue';
+import { getGoafData } from '../cad.api';
+import { message } from 'ant-design-vue';
+
+interface Props {
+  data: Record<string, any>;
+}
+
+const props = defineProps<Props>();
+
+// 加载状态
+const loading = ref(true);
+// 定时器 ID(用于关闭清除)
+let timer: ReturnType<typeof setInterval> | null = null;
+
+// 固定监测数据对象
+const monitorData = ref({
+  coVal: '',
+  ch4Val: '',
+  c2h4Val: '',
+  c2h2Val: '',
+  co2Val: '',
+  o2Val: '',
+  sourcePressure: '',
+  temperature: '',
+});
+
+// 获取数据方法(抽取出来复用)
+const fetchData = async () => {
+  try {
+    if (!props.data.id || !props.data.mineCode) {
+      throw new Error('缺少 goafId 或 mineCode');
+    }
+
+    // 调用接口
+    const res = await getGoafData({
+      goafId: props.data.id,
+      mineCodeList: props.data.mineCode,
+    });
+
+    // 从 res[0] 中取数据
+    const data = res?.[0] || {};
+    
+    // 赋值到页面
+    monitorData.value = {
+      coVal: data.coVal,
+      ch4Val: data.ch4Val,
+      c2h4Val: data.c2h4Val,
+      c2h2Val: data.c2h2Val,
+      co2Val: data.co2Val,
+      o2Val: data.o2Val,
+      sourcePressure: data.sourcePressure,
+      temperature: data.temperature,
+    };
+  } catch (err) {
+    message.error(`获取监测数据失败:${(err as Error).message}`);
+    console.error('getGoafData error:', err);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 页面打开时请求数据 + 开启定时
+onMounted(async () => {
+  // 首次立即加载
+  await fetchData();
+  
+  // 每 10 秒刷新一次 10000ms = 10s
+  timer = setInterval(() => {
+    fetchData();
+  }, 10000);
+});
+
+// 页面关闭时清除定时器(防止内存泄漏)
+onUnmounted(() => {
+  if (timer) {
+    clearInterval(timer);
+    timer = null;
+  }
+});
+</script>
+
+<style scoped>
+.goaf-popup-container {
+  font-family: sans-serif;
+  width: 700px;
+  max-height: 500px;
+  border-radius: 8px;
+  padding: 12px;
+}
+.loading {
+  color: #666;
+  font-size: 14px;
+  text-align: center;
+  padding: 20px 0;
+}
+
+.record-list {
+  width: 100%;
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+}
+
+.record-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0;
+  box-sizing: border-box;
+  min-height: 50px;
+}
+
+
+.label {
+  flex-shrink: 0; /* 防止标签被挤压 */
+  font-size: 14px;
+  width: 160px;
+  height: 100%;
+  color: #53657a;
+  background-color: #f5f5f5;
+  padding-left: 10px;
+  text-align: left;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  border: 1px solid #ddd;
+}
+
+.value {
+  width:100%;
+  height: 100%;
+  font-size: 13px;
+  color: #666;
+  padding-right: 8px;
+  word-break: break-all; /* 超长内容换行,避免溢出 */
+  border: 1px solid #ddd;
+  border-left: none;
+  display: flex;
+  align-items: center;
+  padding-left: 10px;
+}
+
+.no-data {
+  color: #999;
+  font-size: 12px;
+  text-align: center;
+  padding: 10px 0;
+  grid-column: 1 / 4; /* 无数据时跨三列居中 */
+}
+</style>

+ 45 - 7
src/views/system/cadFile/index.vue

@@ -1,18 +1,32 @@
 <template>
   <div id="map3dContainer"></div>
+  <!-- 操作按钮面板 -->
+  <div class="map-operation-panel">
+    <button @click="drawerVisible = true" class="btn">密闭列表</button>
+  </div>
+
+    <SideDrawer v-model:visible="drawerVisible" />
 </template>
 
-<script lang="ts" name="cad-file" setup>
-//ts语法
-import { nextTick, onMounted } from 'vue';
+<script lang="ts" setup>
+import { nextTick, onMounted,ref } from 'vue';
+// 直接导入app.ts中导出的方法
 import { initMap2d } from './app';
+import { message } from 'ant-design-vue';
+// 导入侧边弹框组件
+import SideDrawer from '../cadFile/components/SideDrawer.vue';
 
+// 定义弹框显隐状态
+const drawerVisible = ref(false);
 
+// 初始化地图(确保先加载地图,再调用其他方法)
 onMounted(() => {
-  nextTick(() => {
-    initMap2d();
+  nextTick(async () => {
+    await initMap2d();
+    message.success("地图初始化完成!");
   });
-})
+});
+
 
 </script>
 
@@ -25,4 +39,28 @@ onMounted(() => {
   top: 0;
   left: 0;
 }
-</style>
+
+/* 操作面板样式 */
+.map-operation-panel {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  z-index: 999;
+  display: flex;
+  gap: 8px;
+}
+
+.btn {
+  padding: 8px 12px;
+  background: #409eff;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.btn:hover {
+  background: #66b1ff;
+}
+</style>