Browse Source

[Wip 0000] 测风网络拓扑图功能迁移

houzekong 4 days ago
parent
commit
b5a6721d0f

BIN
src/assets/images/vent/home/turn-topology-bg.png


+ 85 - 6
src/views/vent/home/configurable/configurable.data.ts

@@ -6366,28 +6366,107 @@ export const testConfigVent182: Config[] = [
             {
               label: '总回风量(m³/min)',
               value: '${midinfo[0].sysinfo.info.totalRetM3}',
+              // list: {
+              //   readForm: '${windrect_hui}',
+              // },
+            },
+            {
+              label: '总进风量(m³/min)',
+              value: '${midinfo[0].sysinfo.info.totalIntM3}',
+              // list: {
+              //   readForm: '${windrect_jin}',
+              // },
+            },
+            {
+              label: '计划风量(m³/min)',
+              value: '${midinfo[0].sysinfo.info.totalPlanM3}',
+            },
+            {
+              label: '通风巷道长度(万m)',
+              value: '${midinfo[0].sysinfo.info.flength}',
+            },
+            {
+              label: '有效风量率(%)',
+              value: '${midinfo[0].sysinfo.info.useM3Perent}',
+            },
+          ],
+        },
+      ],
+      list: [],
+      chart: [],
+      table: [],
+      gallery: [],
+      complex_list: [],
+      gallery_list: [],
+      board: [],
+      to: '',
+    },
+    showStyle: {
+      size: '',
+      version: '原版',
+      position: '',
+    },
+  },
+  {
+    deviceType: '',
+    moduleName: 'HIDDEN',
+    pageType: 'vent_182_realtime',
+    moduleData: {
+      header: {
+        show: false,
+        readFrom: '',
+        selector: {
+          show: false,
+          value: '',
+        },
+        slot: {
+          show: false,
+          value: '',
+        },
+      },
+      background: {
+        show: false,
+        type: 'image',
+        link: '',
+      },
+      layout: {
+        direction: 'column',
+        items: [
+          {
+            name: 'plain',
+            basis: '100%',
+          },
+        ],
+      },
+      preset: [
+        {
+          readFrom: '',
+          items: [
+            {
+              label: '总回风量(m³/min)',
+              value: '${midinfo[0].sysdata.zonghuifeng}',
               list: {
                 readForm: '${windrect_hui}',
               },
             },
             {
               label: '总进风量(m³/min)',
-              value: '${midinfo[0].sysinfo.info.totalIntM3}',
+              value: '${midinfo[0].sysdata.zongjinfeng}',
               list: {
                 readForm: '${windrect_jin}',
               },
             },
             {
               label: '计划风量(m³/min)',
-              value: '${midinfo[0].sysinfo.info.totalPlanM3}',
+              value: '${midinfo[0].sysdata.xufengliang}',
             },
             {
               label: '通风巷道长度(万m)',
-              value: '${midinfo[0].sysinfo.info.flength}',
+              value: '${midinfo[0].sysinfo.totallength}',
             },
             {
               label: '有效风量率(%)',
-              value: '${midinfo[0].sysinfo.info.useM3Perent}',
+              value: '${midinfo[0].sysinfo.useM3Perent}',
             },
           ],
         },
@@ -6402,9 +6481,9 @@ export const testConfigVent182: Config[] = [
       to: '',
     },
     showStyle: {
-      size: 'width:420px;height:280px;',
+      size: '',
       version: '原版',
-      position: 'top:60px;left:0;',
+      position: '',
     },
   },
   {

+ 152 - 11
src/views/vent/monitorManager/deviceMonitor/components/device/index.vue

@@ -31,6 +31,15 @@
       <SvgIcon size="18" name="put-away" />
       <span class="location-text">定位图标显示</span>
     </div>
+    <div
+      v-if="deviceType.startsWith('windrect')"
+      class="topology-icon"
+      :class="{ 'topology-btn-show': !topologyShow, 'topology-btn-hide': topologyShow }"
+      @click="showTree('windTopology', true)"
+    >
+      <SvgIcon size="18" name="put-away" />
+      <span class="topology-text">测风网络拓扑</span>
+    </div>
     <div class="location-select" :class="{ 'location-select-show': locationSettingShow, 'location-select-hide': !locationSettingShow }">
       <div class="location-select-box">
         <div class="location-top-title" @click="showTree('location', false)">
@@ -55,6 +64,17 @@
         </div>
       </div>
     </div>
+    <div class="topology-select" :class="{ 'topology-select-show': topologyShow, 'topology-select-hide': !topologyShow }">
+      <div class="topology-select-box">
+        <div class="topology-top-title" @click="showTree('windTopology', false)">
+          <SvgIcon class="is-expansion-icon topology-expansion-icon" size="28" name="expansion" />
+          <div class="title">测风网络拓扑</div>
+        </div>
+        <div class="topology-container">
+          <WindTopology v-if="topologyShow" />
+        </div>
+      </div>
+    </div>
     <div
       class="bottom-tabs-box"
       :class="{ 'table-hide': !tableShow, 'table-show': tableShow }"
@@ -173,13 +193,13 @@
               deviceType.startsWith('gasDay_normal')
                 ? '当日巡检监测'
                 : deviceType.startsWith('dustDayReport') ||
-                    deviceType.startsWith('bundleDayReport') ||
-                    deviceType.startsWith('bundleSpyDayReport') ||
-                    deviceType.startsWith('bundleReport') ||
-                    deviceType.startsWith('bundleSpyReport') ||
-                    deviceType.startsWith('dustReport')
-                  ? '最新监测报表'
-                  : '实时监测'
+                  deviceType.startsWith('bundleDayReport') ||
+                  deviceType.startsWith('bundleSpyDayReport') ||
+                  deviceType.startsWith('bundleReport') ||
+                  deviceType.startsWith('bundleSpyReport') ||
+                  deviceType.startsWith('dustReport')
+                ? '最新监测报表'
+                : '实时监测'
             "
           >
             <template v-if="(deviceType.startsWith('fanlocal') || deviceType.startsWith('fanmain')) && activeKey == 'monitor'">
@@ -643,6 +663,7 @@
   import { ref, onMounted, onUnmounted, ComponentOptions, shallowRef, reactive, watch } from 'vue';
   import { SendOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons-vue';
   import { list, getDeviceList, getDeviceTypeList, devPosition, getDepartmentInfo, getExportUrl, getRegulation } from './device.api';
+  import WindTopology from '/@/views/vent/monitorManager/windrectMonitor/topology/WindTopology.vue';
   import AlarmHistoryTable from '../../../comment/AlarmHistoryTable.vue';
   import HistoryTable from '../../../comment/HistoryTable.vue';
   import HistoryTableNew from '/@/views/vent/comment/history/HistoryTable.vue';
@@ -744,6 +765,7 @@
   const treeShow = ref(true); //是否显示树形菜单
   const tableShow = ref(true); //是否显示树形菜单
   const locationSettingShow = ref(false); //是否显示树形菜单
+  const topologyShow = ref(false); // 是否显示测风网络拓扑面板
   const treeNodeTitle = ref(''); // 选中的树形标题
 
   const locationList = ref([]); //巷道定位图标显示列表
@@ -822,6 +844,7 @@
   function showTree(flag, value) {
     if (flag == 'treeShow') treeShow.value = value;
     if (flag == 'location') locationSettingShow.value = value;
+    if (flag == 'windTopology') topologyShow.value = value;
   }
 
   async function getDeviceType(sysType?) {
@@ -1139,10 +1162,7 @@
     }
   }
   function getSearch(param) {
-    ((gasSearch.address = param.address),
-      (gasSearch.userName = param.userName),
-      (gasSearch.insType = param.insType),
-      (gasSearch.class = param.class));
+    (gasSearch.address = param.address), (gasSearch.userName = param.userName), (gasSearch.insType = param.insType), (gasSearch.class = param.class);
   }
   // 详情跳转
   function goDetail(record?) {
@@ -1509,6 +1529,7 @@
     --image-location-bg: url('/@/assets/images/vent/home/location-bg.png');
     --image-location-hover-bg: url('/@/assets/images/vent/home/location-hover-bg.png');
     --image-turn-location-top-bg: url('/@/assets/images/vent/home/turn-location-top-bg.png');
+    --image-turn-topology-bg: url('/@/assets/images/vent/home/turn-topology-bg.png');
     --image-tree-icon-cover-bg: url('/@/assets/images/vent/home/tree-icon-cover-bg.png');
     --image-tree-icon-cover-hover-bg: url('/@/assets/images/vent/home/tree-icon-cover-hover-bg.png');
     --image-tohome: url('/@/assets/images/vent/home/tohome.png');
@@ -1853,6 +1874,126 @@
     transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
   }
 
+  .topology-icon {
+    position: absolute;
+    top: 300px;
+    width: 46px;
+    height: 178px;
+    background: var(--image-location-bg) no-repeat;
+    color: var(--vent-font-color);
+    cursor: pointer;
+    background-size: contain;
+    writing-mode: vertical-lr;
+    line-height: 46px;
+    padding-top: 10px;
+    pointer-events: auto;
+
+    &:hover {
+      background: var(--image-location-hover-bg) no-repeat;
+    }
+
+    .topology-text {
+      padding-top: 16px;
+      letter-spacing: 2px;
+      font-size: 15px;
+    }
+  }
+
+  .topology-btn-show {
+    right: -0;
+    animation-name: locationBtnShow;
+    /* 持续时间 */
+    animation-duration: 1s;
+    transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
+  }
+
+  .topology-btn-hide {
+    right: -240px;
+    animation-name: locationBtnHide;
+    /* 持续时间 */
+    animation-duration: 1s;
+    transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
+  }
+
+  // 测风网络拓扑面板:复用定位图标面板结构,适配大面板尺寸 + 右滑入/滑出
+  // ===== 测风网络拓扑面板:独立 CSS 类(样式参考 location-select-* 定义)=====
+  .topology-select {
+    position: fixed;
+    top: 100px;
+    pointer-events: auto;
+    width: 640px;
+    height: 510px;
+    transition: right 1s cubic-bezier(0.165, 0.84, 0.44, 1);
+
+    .topology-select-box {
+      position: relative;
+      width: 100%;
+      height: 100%;
+      // border: 1px solid @border-color-base;
+      // background-color: #394664;
+      background: var(--image-turn-topology-bg) no-repeat;
+      background-size: 100% 100%;
+
+      .topology-top-title {
+        // position: absolute;
+        // top: 5px;
+        // left: 5px;
+        display: flex;
+        flex-direction: row;
+        align-items: flex-end;
+        justify-content: space-between;
+        // width: 225px;
+        height: 58px;
+        color: var(--vent-font-color);
+        background-size: contain;
+        padding: 20px 10px 0 10px;
+
+        .title {
+          font-size: 18px;
+          // position: relative;
+          // top: -14px;
+          right: 15px;
+          color: #fff;
+          // color: #06435f;
+        }
+
+        .topology-expansion-icon {
+          // position: relative;
+          // top: -15px;
+          // left: 10px;
+          padding: 5px;
+          background: var(--image-tree-icon-cover-bg) no-repeat;
+          background-size: contain;
+
+          &:hover {
+            background: var(--image-tree-icon-cover-hover-bg) no-repeat;
+            background-size: contain;
+          }
+        }
+      }
+
+      .topology-container {
+        // position: absolute;
+        // top: 92px;
+        // left: 18px;
+        // width: calc(min(1040px, 84vw) - 36px);
+        height: 460px;
+        margin-top: 20px;
+        overflow: hidden;
+      }
+    }
+  }
+
+  .topology-select-show {
+    right: 10px;
+    animation-name: none;
+  }
+
+  .topology-select-hide {
+    right: -650px;
+    animation-name: none;
+  }
+
   .tabs-box {
     height: 290px;
   }

+ 314 - 0
src/views/vent/monitorManager/windrectMonitor/topology/WindTopology.vue

@@ -0,0 +1,314 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<template>
+  <div class="wind-topology">
+    <!-- 工具栏 -->
+    <div class="topo-toolbar">
+      <!-- <div class="toolbar-left"> -->
+      <div>
+        <a-button-group>
+          <a-button size="small" @click="zoomIn" title="放大" :icon="h(PlusOutlined)" />
+          <a-button size="small" @click="zoomOut" title="缩小" :icon="h(MinusOutlined)" />
+        </a-button-group>
+        <a-divider type="vertical" />
+        <!-- 双模式互斥:绑定模式(关系编辑)/ 布点模式(设备绑定) -->
+        <a-button-group>
+          <a-button size="small" :type="mode === 'relation' ? 'primary' : 'default'" @click="setMode('relation')">绑定模式</a-button>
+          <a-button size="small" :type="mode === 'device' ? 'primary' : 'default'" @click="setMode('device')">布点模式</a-button>
+        </a-button-group>
+        <a-divider type="vertical" />
+        <a-button-group>
+          <a-button size="small" :loading="saving" @click="handleSaveLayout" title="保存当前节点位置(topologyX/topologyY)">
+            <template #icon><SaveOutlined /></template>
+            保存布局
+          </a-button>
+          <a-button size="small" @click="refreshData" title="刷新">
+            <template #icon><ReloadOutlined /></template>
+            刷新
+          </a-button>
+        </a-button-group>
+        <span v-if="mode === 'relation'" class="ml-5px select-text">双击连线解除绑定关系,双击两个节点绑定关系</span>
+        <span v-else class="ml-5px select-text">双击连线解绑或绑定测风装置</span>
+      </div>
+      <div></div>
+
+      <!-- 图例 -->
+      <div class="toolbar-right">
+        <div class="legend">
+          <span v-for="lv in levelList" :key="lv.level" class="legend-item">
+            <span class="legend-bar" :style="{ background: lv.color }"></span>
+            {{ lv.text }}
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 主体 -->
+    <div class="topo-body">
+      <div ref="chartRef" class="topo-canvas"></div>
+
+      <!-- 详情面板(两种模式单击节点/连线均显示) -->
+      <!-- <div v-if="selectedNode" class="detail-panel">
+        <div class="detail-header">
+          <span class="detail-title">节点详情</span>
+          <a-button size="small" type="text" @click="clearSelection">✕</a-button>
+        </div>
+        <div class="detail-category" :style="{ color: nodeColor(selectedNode) }">
+          {{ categories[selectedNode.category]?.name }}
+        </div>
+        <div class="detail-name">{{ selectedNode.name }}</div>
+        <div class="detail-fields">
+          <div v-for="field in detailFields" :key="field.label" class="detail-row">
+            <span class="detail-label">{{ field.label }}:</span>
+            <span class="detail-value">{{ field.value }}</span>
+          </div>
+        </div>
+      </div> -->
+    </div>
+  </div>
+
+  <!-- 布点模式绑定弹窗(双击未绑定布点的连线弹出;destroyOnClose 保证每次打开重建表单刷新下拉数据) -->
+  <BasicModal
+    @register="registerBindModal"
+    :title="`绑定测风装置至:${bindPoint?.name}`"
+    ok-text="确认"
+    cancel-text="取消"
+    destroyOnClose
+    @ok="handleBindOk"
+    @cancel="handleBindCancel"
+  >
+    <a-form layout="vertical" class="m-5">
+      <a-form-item label="数据点位" required>
+        <ApiSelect
+          v-model:value="bindDeviceId"
+          :api="getWindrectDeviceList"
+          :params="{ devicetype: 'windrect', pagetype: 'normal' }"
+          label-field="strinstallpos"
+          value-field="deviceID"
+          placeholder="请选择数据点位"
+        />
+      </a-form-item>
+    </a-form>
+  </BasicModal>
+</template>
+
+<script setup lang="ts">
+  import { h, onMounted, onUnmounted, ref, watch } from 'vue';
+  import { ApiSelect } from '/@/components/Form';
+  import { BasicModal, useModal } from '/@/components/Modal';
+  import { PlusOutlined, MinusOutlined, SaveOutlined, ReloadOutlined } from '@ant-design/icons-vue';
+  import { levelTextMap, levelColorMap } from './windTopology.data';
+  import { useTopology } from './hooks/useTopology';
+  import { getWindrectDeviceList } from './windTopology.api';
+
+  /** 图例层级项:按 levelTextMap/levelColorMap 顺序生成(总进风 → 总回风) */
+  const levelList = Object.keys(levelTextMap).map((k) => {
+    const level = Number(k);
+    return { level, text: levelTextMap[level], color: levelColorMap[level] };
+  });
+
+  /** 与 renderTopology 一致的节点配色:测风地点灰色、地面用分类色 */
+  // function nodeColor(node: any): string {
+  //   if (node?.category === 1) return POINT_COLOR;
+  //   return categories[node?.category]?.color || '#333';
+  // }
+
+  const {
+    chartRef,
+    mode,
+    setMode,
+    bindVisible,
+    bindPoint,
+    bindDeviceId,
+    confirmBind,
+    closeBindDialog,
+    // selectedNode,
+    // detailFields,
+    initChart,
+    loadTopology,
+    zoomIn,
+    zoomOut,
+    // clearSelection,
+    saveTopologyLayout,
+    dispose,
+  } = useTopology();
+
+  /** 保存布局按钮 loading 状态 */
+  const saving = ref(false);
+
+  /** 保存当前拓扑布局(节点位置 → topologyX/topologyY) */
+  async function handleSaveLayout() {
+    saving.value = true;
+    try {
+      await saveTopologyLayout();
+    } finally {
+      saving.value = false;
+    }
+  }
+
+  /** 布点模式绑定弹窗(useModal 钩子调用;destroyOnClose 每次打开重建表单刷新下拉数据) */
+  const [registerBindModal, { openModal, closeModal }] = useModal();
+
+  // 双击未绑定布点的连线置 bindVisible=true → 打开模态框;确认/取消置 false → 关闭
+  watch(bindVisible, (v) => {
+    if (v) {
+      openModal(true);
+    } else {
+      closeModal();
+    }
+  });
+
+  function handleBindOk() {
+    confirmBind(bindDeviceId.value);
+  }
+
+  function handleBindCancel() {
+    closeBindDialog();
+  }
+
+  function refreshData() {
+    loadTopology();
+  }
+
+  onMounted(() => {
+    if (!chartRef.value) return;
+    initChart(chartRef.value);
+    loadTopology();
+  });
+
+  onUnmounted(() => {
+    dispose();
+  });
+</script>
+
+<style lang="less" scoped>
+  // 内嵌内容组件:填满父级 .location-container(外边框/标题由父级 location-select 提供)
+  .wind-topology {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    background: transparent;
+  }
+
+  .topo-toolbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    flex-wrap: wrap;
+    row-gap: 6px;
+    padding: 8px 16px;
+    background: transparent;
+    border-bottom: 1px solid rgba(95, 244, 255, 0.25);
+    flex-shrink: 0;
+
+    .toolbar-left {
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      flex: 1 1 55%;
+      gap: 8px;
+    }
+    .select-text {
+      font-size: 12px;
+      // color: var(--vent-table-action-link, #00e7ff);
+      color: @white;
+      white-space: nowrap;
+    }
+
+    .toolbar-right {
+      display: flex;
+      align-items: flex-start;
+      // justify-content: flex-end;
+      flex: 1 1 auto;
+      gap: 16px;
+      padding-right: 12px;
+
+      .legend {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: flex-end;
+        gap: 12px;
+        align-items: center;
+        .legend-item {
+          display: flex;
+          align-items: center;
+          font-size: 12px;
+          color: var(--vent-font-color, #fff);
+          gap: 4px;
+          .legend-bar {
+            display: inline-block;
+            width: 18px;
+            height: 3px;
+            border-radius: 2px;
+          }
+        }
+      }
+    }
+  }
+
+  .topo-body {
+    flex: 1;
+    position: relative;
+    overflow: hidden;
+    // 画布区加一层半透明深底,让明亮层级色与浅色标签更突出
+    background: rgba(5, 30, 48, 0.28);
+    height: 100%;
+  }
+
+  .topo-canvas {
+    width: 100%;
+    height: 100%;
+  }
+
+  .detail-panel {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+    width: 240px;
+    background: rgba(6, 34, 52, 0.92);
+    border: 1px solid var(--vent-base-border, #5cfaff);
+    border-radius: 6px;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
+    padding: 14px 16px;
+    z-index: 10;
+
+    .detail-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 8px;
+      .detail-title {
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--vent-font-action-link, #7af5ff);
+      }
+    }
+    .detail-category {
+      font-size: 12px;
+      font-weight: 500;
+      margin-bottom: 2px;
+      color: #bfe9ff;
+    }
+    .detail-name {
+      font-size: 16px;
+      font-weight: 600;
+      color: #fff;
+      margin-bottom: 10px;
+    }
+    .detail-fields .detail-row {
+      display: flex;
+      justify-content: space-between;
+      padding: 4px 0;
+      font-size: 13px;
+      border-bottom: 1px solid rgba(122, 245, 255, 0.15);
+      .detail-label {
+        color: #8fb8cc;
+      }
+      .detail-value {
+        color: #e8f4ff;
+        font-weight: 500;
+      }
+    }
+  }
+</style>

+ 575 - 0
src/views/vent/monitorManager/windrectMonitor/topology/hooks/useTopology.ts

@@ -0,0 +1,575 @@
+import { ref } from 'vue';
+import * as echarts from 'echarts';
+import type { EChartsOption } from 'echarts';
+import { message, Modal } from 'ant-design-vue';
+import { createGraphOption, categories, levelColorMap, nodeDetailFields, POINT_COLOR, POINT_SIZE, LAYOUT } from '../windTopology.data';
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+import {
+  getTopologyData,
+  updateWindMonitorArea,
+  addWindMonitorAreaRelation,
+  deleteWindMonitorAreaRelation,
+  updateWindMonitorAreaTopology,
+} from '../windTopology.api';
+import { computeLayout } from './useTopologyLayout';
+
+/** 拓扑交互模式:relation=绑定模式(关系编辑)、device=布点模式(设备绑定/解绑) */
+export type TopoMode = 'relation' | 'device';
+
+export function useTopology() {
+  const chartRef = ref<HTMLDivElement | null>(null);
+  let chartInstance: echarts.ECharts | null = null;
+  let topologyData: TopologyData = { nodes: [], links: [] };
+  /** 测风装置显示名映射(rawId → devicePos/strinstallpos/strname/id,取自全量 windrectList):
+   *  已绑定设备不再绘制节点,其名称由该映射写入巷道连线 edgeLabel 及解绑确认文案 */
+  let deviceNameMap: Record<string, string> = {};
+  let resizeObserver: ResizeObserver | null = null;
+
+  // —— 模式状态 ——
+  /** 当前模式:默认绑定模式;两种模式均保留悬浮高亮与单击详情 */
+  const mode = ref<TopoMode>('relation');
+  /** 绑定模式选中的关联源节点(双击节点选中,再单击另一节点建立关系;红色描边高亮) */
+  const armedSource = ref<TopoNodeData | null>(null);
+  /** 布点模式绑定弹窗:双击未绑定布点的连线后弹出选择测风装置 */
+  const bindVisible = ref(false);
+  const bindPoint = ref<TopoNodeData | null>(null);
+  const bindDeviceId = ref<string | undefined>(undefined);
+
+  // —— 选中节点详情 ——
+  const selectedNode = ref<TopoNodeData | null>(null);
+  const detailFields = ref<any[]>([]);
+
+  // —— 初始化 ——
+  function initChart(el: HTMLDivElement) {
+    chartInstance = echarts.init(el);
+    window.addEventListener('resize', handleResize);
+    // 容器尺寸变化(如面板由隐藏变为可见 / 窗口尺寸变化)时自适应重绘,
+    // 避免 0×0 初始化导致节点布局越界而空白
+    if (typeof ResizeObserver !== 'undefined') {
+      resizeObserver = new ResizeObserver(() => {
+        if (!chartInstance) return;
+        const rect = el.getBoundingClientRect();
+        if (rect.width > 0 && rect.height > 0) {
+          chartInstance.resize();
+          renderTopology(topologyData);
+        }
+      });
+      resizeObserver.observe(el);
+    }
+    return chartInstance;
+  }
+
+  async function loadTopology() {
+    try {
+      // 接口获取 → 代码处理(关系/地面节点)已由 getTopologyData 完成(无参数,全局),
+      // 此处直接消费全量数据;接口异常时 bundle 为备用空数据(仍可绘制总进地面节点)
+      const bundle = await getTopologyData();
+      armedSource.value = null;
+      // 测风装置显示名映射(全量列表 → 已绑定设备的 edgeLabel/解绑文案展示用)
+      deviceNameMap = {};
+      for (const d of bundle.windrectList) {
+        const key = d.deviceID ?? d.id;
+        if (key) deviceNameMap[String(key)] = d.devicePos || d.strinstallpos || d.strname || d.deviceCode || String(key);
+      }
+      renderTopology(bundle.topology);
+      // 数据为空时给出诊断信息(总进地面节点仍会渲染)
+      if (!bundle.topology.nodes.some((n) => n.category !== 0)) {
+        console.warn('拓扑数据为空:未获取到测风地点');
+        message.warning('未获取到拓扑数据,请检查接口或稍后刷新');
+      }
+    } catch (e) {
+      console.error('拓扑数据加载失败:', e);
+      message.error('拓扑数据加载失败,请查看控制台日志');
+    }
+  }
+
+  // —— 渲染拓扑 ——
+  function renderTopology(data: TopologyData) {
+    topologyData = data;
+    if (!chartInstance) return;
+
+    const option: EChartsOption = createGraphOption();
+    const series: any = (option.series as any[])[0];
+
+    const cw = chartInstance.getWidth();
+    const ch = chartInstance.getHeight();
+    // 横向列式布局(总进 → lv0..lv6 → 未分级 → 总回,从左到右)
+    const layout = computeLayout(data, cw, ch);
+    const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
+    // roadway 边主要数据点位(名称已在连线中点展示,节点自身不再显示文本标签,避免重复)
+    const roadwayMainIds = new Set(data.links.filter((l) => l.kind === 'roadway' && l.mainPointId).map((l) => l.mainPointId));
+
+    // 最终按 id 去重兜底(数据层已去重,此处防止异常数据导致 ECharts 重复 id 报错)
+    const seenIds = new Set<string>();
+    const echartsNodes: any[] = [];
+    for (const n of data.nodes) {
+      if (seenIds.has(n.id)) {
+        console.warn('拓扑节点 id 重复,已跳过:', n.id);
+        continue;
+      }
+      seenIds.add(n.id);
+      const cat = categories[n.category] || categories[0];
+      // 节点配色:测风地点统一灰色小圆点(层级/类型区分转移到连线颜色);地面节点使用分类色
+      let nodeColor = cat.color;
+      if (n.category === 1) {
+        nodeColor = POINT_COLOR;
+      }
+      const itemStyle: any = { color: nodeColor };
+      // 测风地点统一为灰色小圆点(白描边衬底,避免与彩色连线粘连)
+      if (n.category === 1) {
+        itemStyle.borderColor = '#ffffff';
+        itemStyle.borderWidth = 1.5;
+      }
+      // 绑定模式选中的关联源节点:红色醒目标记
+      if (armedSource.value && armedSource.value.id === n.id) {
+        itemStyle.borderColor = '#f5222d';
+        itemStyle.borderWidth = 3;
+      }
+      const pos = layout.positions[n.id];
+      const en: any = {
+        id: n.id,
+        name: n.name,
+        category: n.category,
+        value: n.name,
+        symbol: n.category === 1 ? 'circle' : cat.symbol,
+        symbolSize: n.category === 1 ? POINT_SIZE : cat.symbolSize,
+        itemStyle,
+        raw: n,
+      };
+      // 巷道连线主要数据点位:名称已在连线中点标注展示,节点本身不再显示文本标签(避免重复)
+      if (n.category === 1 && roadwayMainIds.has(n.id)) {
+        en.label = { show: false };
+      }
+      if (pos) {
+        en.x = pos.x;
+        en.y = pos.y;
+        // 地面节点固定不可拖;测风地点可拖动(配合"保存布局"持久化位置)
+        if (n.category === 0) {
+          en.fixed = true;
+        }
+      }
+      echartsNodes.push(en);
+    }
+
+    // 绘制层级:同一 series 内后绘制者在上。地面保持最底绘制
+    const renderPriority: Record<number, number> = { 0: 0, 1: 1 };
+    echartsNodes.sort((a, b) => (renderPriority[a.category] ?? 0) - (renderPriority[b.category] ?? 0));
+    series.nodes = echartsNodes;
+    series.links = data.links
+      .map((l) => {
+        const link: any = {
+          source: l.source,
+          target: l.target,
+          kind: l.kind,
+          pointId: l.pointId,
+          mainPointId: l.mainPointId,
+          relationId: l.relationId,
+          flow: l.flow,
+        };
+        if (l.kind === 'roadway') {
+          // 巷道连线:直线(无曲率)+ 按主要数据点位 level 用 levelColorMap 着色 + 风流方向箭头;
+          // 中点标注(edgeLabel rich 富文本):第一行主要数据点位名称、第二行圆点 + 已绑定设备名;
+          // 未绑定时第二行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
+          const point = l.mainPointId ? pointById.get(l.mainPointId) : undefined;
+          const lv = point ? Number(point.level) : NaN;
+          const deviceName = point?.deviceId ? deviceNameMap[point.deviceId] : '';
+          // 常态标注:仅名称;高亮标注:名称 + 圆点与已绑定设备名(未绑定输出浅灰占位圆点)
+          link.midLabel = point ? `{name|${point.name}}` : '';
+          link.midLabelFull = point ? `{name|${point.name}}\n${deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'}` : '';
+          link.lineStyle = {
+            color: levelColorMap[lv] || POINT_COLOR,
+            width: 3,
+            opacity: 0.85,
+            curveness: 0,
+          };
+          link.edgeSymbol = ['none', 'arrow'];
+          link.edgeSymbolSize = [0, 10];
+        } else {
+          link.lineStyle = { color: '#8c8c8c', width: 1.5, opacity: 0.6, curveness: 0 };
+        }
+        return link;
+      })
+      .filter(Boolean);
+
+    chartInstance.setOption(option, true);
+    registerEvents();
+  }
+
+  // ==================== 交互事件 ====================
+
+  function registerEvents() {
+    if (!chartInstance) return;
+    chartInstance.off('click');
+    chartInstance.off('dblclick');
+
+    // ——— 单击 ———
+    chartInstance.on('click', (params: any) => {
+      if (!params.data) {
+        // 单击空白:清除选中
+        clearArmed();
+        return;
+      }
+      if (params.dataType === 'node') {
+        const raw = params.data.raw as TopoNodeData;
+        if (!raw) return;
+        // 绑定模式:已选中关联源且单击另一节点 → 显示详情的同时建立关系
+        if (mode.value === 'relation' && armedSource.value && armedSource.value.id !== raw.id) {
+          showNodeDetail(raw);
+          handleNodeRelation(raw);
+          return;
+        }
+        showNodeDetail(raw);
+        return;
+      }
+      if (params.dataType === 'edge') {
+        // 单击巷道连线 → 显示其主要数据测风地点详情(进风/用风为子节点、回风为父节点)
+        const mainId = params.data.mainPointId || params.data.pointId;
+        if (params.data.kind === 'roadway' && mainId) {
+          const point = topologyData.nodes.find((n) => n.id === mainId);
+          if (point) showNodeDetail(point);
+        }
+        clearArmed();
+      }
+    });
+
+    // ——— 双击 ———
+    chartInstance.on('dblclick', (params: any) => {
+      if (!params.data) return;
+      if (params.dataType === 'edge' && params.data.kind === 'roadway') {
+        if (mode.value === 'relation') {
+          // 绑定模式:双击连线 → 解除巷道关系(传关系 id)
+          handleRemoveRelation(params.data);
+        } else {
+          // 布点模式:双击连线 → 对其主要数据点位解绑/绑定测风装置
+          const mainId = params.data.mainPointId || params.data.pointId;
+          const point = mainId ? topologyData.nodes.find((n) => n.id === mainId) : undefined;
+          if (point) handleDeviceAction(point);
+        }
+        return;
+      }
+      if (params.dataType === 'node') {
+        const raw = params.data.raw as TopoNodeData;
+        if (!raw) return;
+        if (mode.value === 'relation') {
+          // 绑定模式:双击节点 → 选中/取消选中为关联源
+          armedSource.value = armedSource.value && armedSource.value.id === raw.id ? null : raw;
+          renderTopology(topologyData);
+        }
+      }
+    });
+  }
+
+  // ==================== 绑定模式:关系编辑 ====================
+
+  /** 清除绑定模式的关联源选中态 */
+  function clearArmed() {
+    if (!armedSource.value) return;
+    armedSource.value = null;
+    renderTopology(topologyData);
+  }
+
+  /**
+   * 绑定模式:双击节点 A 后单击节点 B → 建立巷道关系。
+   * 父子按 level 判定:level 小者为父、大者为子;level 相同时提示不能绑定。
+   */
+  function handleNodeRelation(target: TopoNodeData) {
+    const source = armedSource.value;
+    if (!source) return;
+    const sl = Number(source.level);
+    const tl = Number(target.level);
+    if (sl === tl) {
+      message.warning('两个点位层级相同,不能建立巷道关系');
+      clearArmed();
+      return;
+    }
+    const parent = sl < tl ? source : target;
+    const child = parent === source ? target : source;
+    if (!parent.rawId || !child.rawId) {
+      message.warning('缺少点位 id,无法建立关系');
+      clearArmed();
+      return;
+    }
+    Modal.confirm({
+      title: '确认建立巷道关系',
+      content: `确定建立"${parent.name} → ${child.name}"的巷道关系?`,
+      okText: '确认',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await addWindMonitorAreaRelation({ parentId: parent.rawId, childId: child.rawId });
+          clearArmed();
+          await loadTopology();
+          message.success('关联成功');
+        } catch {
+          message.error('关联失败');
+        }
+      },
+      onCancel: () => clearArmed(),
+    });
+  }
+
+  /** 绑定模式:双击连线 → 解除巷道关系(deleteWindMonitorAreaRelation 传入关系 id) */
+  function handleRemoveRelation(link: any) {
+    if (!link.relationId) {
+      message.info('该连线为根连线,无可解除的巷道关系');
+      return;
+    }
+    const sourceNode = topologyData.nodes.find((n) => n.id === link.source);
+    const targetNode = topologyData.nodes.find((n) => n.id === link.target);
+    const text = `${sourceNode?.name || link.source} → ${targetNode?.name || link.target}`;
+    Modal.confirm({
+      title: '确认解除巷道关系',
+      content: `确定解除"${text}"的巷道关系?解除后该连线将消失。`,
+      okText: '确认解除',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await deleteWindMonitorAreaRelation({ ids: link.relationId });
+          await loadTopology();
+          message.success('解除巷道关系成功');
+        } catch {
+          message.error('解除巷道关系失败');
+        }
+      },
+    });
+  }
+
+  // ==================== 布点模式:设备绑定/解绑 ====================
+
+  /** 按测风装置原始 id 解析其显示名(用于确认文案,避免直接展示 id) */
+  function deviceNameOf(rawId?: string) {
+    return (rawId && deviceNameMap[rawId]) || rawId || '';
+  }
+
+  /** 布点模式:双击连线 → 已有测风装置则解绑,否则弹窗选择绑定 */
+  function handleDeviceAction(point: TopoNodeData) {
+    if (point.deviceId) {
+      confirmUnbindDevice(point);
+    } else {
+      bindPoint.value = point;
+      bindDeviceId.value = undefined;
+      bindVisible.value = true;
+    }
+  }
+
+  /** 关闭布点绑定弹窗(WindTopology.vue 取消/关闭时同步,成功关闭由 confirmBind 内部调用) */
+  function closeBindDialog() {
+    bindVisible.value = false;
+  }
+
+  /** 布点模式:弹窗选择测风装置后调用(updateWindMonitorArea 更新 deviceId 字段) */
+  async function confirmBind(deviceId?: string) {
+    const point = bindPoint.value;
+    if (!point?.rawId) {
+      message.warning('缺少测风地点原始 id,无法绑定');
+      return;
+    }
+    if (!deviceId) {
+      message.warning('请选择数据点位');
+      return;
+    }
+    try {
+      await updateWindMonitorArea({ id: point.rawId, deviceId });
+      closeBindDialog();
+      await loadTopology();
+      message.success('绑定成功');
+    } catch {
+      message.error('绑定失败');
+    }
+  }
+
+  /** 布点模式:解绑测风装置(清空 deviceId 字段,updateWindMonitorArea) */
+  function confirmUnbindDevice(point: TopoNodeData) {
+    const deviceName = deviceNameOf(point.deviceId);
+    Modal.confirm({
+      title: '确认解绑',
+      content: `确定解除"${point.name}"与测风装置"${deviceName}"的绑定关系?`,
+      okText: '确认解绑',
+      cancelText: '取消',
+      onOk: async () => {
+        if (!point.rawId) {
+          message.warning('缺少测风地点原始 id,无法解绑');
+          return;
+        }
+        try {
+          await updateWindMonitorArea({ id: point.rawId, deviceId: '' });
+          await loadTopology();
+          message.success('解绑成功');
+        } catch {
+          message.error('解绑失败');
+        }
+      },
+    });
+  }
+
+  // ==================== 布局保存 ====================
+
+  /** 获取当前 graph 系列的 SeriesData(ECharts 类型中 getModel 为私有,运行时可用) */
+  function seriesDataOf(): any {
+    if (!chartInstance) return null;
+    const seriesModel = (chartInstance as any).getModel().getSeriesByIndex(0) as any;
+    return seriesModel?.getData?.() || null;
+  }
+
+  /**
+   * 从 ECharts 实例一次性读取全部节点当前渲染坐标(含拖动后位置)。
+   * 坐标来源(按优先级):
+   *   1. SeriesData.getItemLayout(i)——渲染层布局,节点拖动后由 ECharts 写入,
+   *      注意 graph 返回格式为 [x, y] 数组(非 {x, y} 对象);
+   *   2. getRawDataItem(i) 的 x/y(初始 setOption 传入的坐标)。
+   * 节点 id 通过 data.getId(i) 关联(renderTopology 中 en.id = n.id)。
+   */
+  function getChartNodePositions(): Map<string, { x: number; y: number }> {
+    const positions = new Map<string, { x: number; y: number }>();
+    const data = seriesDataOf();
+    if (!data) return positions;
+    const count = data.count();
+    for (let i = 0; i < count; i++) {
+      const id = data.getId?.(i);
+      if (id == null || id === '') continue;
+      let x: number | undefined;
+      let y: number | undefined;
+      // 1) 渲染层布局:graph 拖动后坐标为 [x, y] 数组;兼容对象形式
+      const layout = data.getItemLayout?.(i);
+      if (Array.isArray(layout) && layout.length >= 2) {
+        x = layout[0];
+        y = layout[1];
+      } else if (layout && typeof layout.x === 'number' && typeof layout.y === 'number') {
+        x = layout.x;
+        y = layout.y;
+      }
+      // 2) 回退:原始 data item 的 x/y(初始布局坐标)
+      if (!(Number.isFinite(x) && Number.isFinite(y))) {
+        const raw = data.getRawDataItem?.(i);
+        if (raw && Number.isFinite(raw.x) && Number.isFinite(raw.y)) {
+          x = raw.x;
+          y = raw.y;
+        }
+      }
+      if (Number.isFinite(x) && Number.isFinite(y)) {
+        positions.set(String(id), { x: x as number, y: y as number });
+      }
+    }
+    return positions;
+  }
+
+  /**
+   * 保存当前拓扑布局:将全部测风地点(含 level 0/6)当前位置写入
+   * updateWindMonitorAreaTopology 的 topologyX/topologyY 字段(ECharts 画布像素坐标)。
+   * 坐标在点击保存时一次性从 ECharts 实例读取,并按 LAYOUT.snapGrid(对齐像素值)取整。
+   * 返回是否全部保存成功。
+   */
+  async function saveTopologyLayout(): Promise<boolean> {
+    const points = topologyData.nodes.filter((n) => n.category === 1 && n.rawId);
+    if (points.length === 0) {
+      message.warning('当前无可保存的测风地点');
+      return false;
+    }
+    const positions = getChartNodePositions();
+    const grid = LAYOUT.snapGrid || 10;
+    // 仅提交能读到坐标的节点(缺失坐标的节点跳过,避免 Promise/空对象混入请求体);
+    // 保存时按配置的对齐像素值取整(对齐网格)
+    const payload = points
+      .map((p) => ({ p, pos: positions.get(p.id) }))
+      .filter((x): x is { p: TopoNodeData; pos: { x: number; y: number } } => Boolean(x.pos))
+      .map(({ p, pos }) => ({
+        id: p.rawId,
+        topologyX: Math.round(pos.x / grid) * grid,
+        topologyY: Math.round(pos.y / grid) * grid,
+      }));
+    if (payload.length === 0) {
+      message.warning('未能读取节点坐标,布局未保存');
+      return false;
+    }
+    await updateWindMonitorAreaTopology(payload);
+    message.success('布局保存完成');
+    return true;
+  }
+
+  // ==================== 节点详情 ====================
+
+  function showNodeDetail(node: TopoNodeData) {
+    selectedNode.value = node;
+    const catName = categories[node.category]?.name || '';
+    const fields = nodeDetailFields[catName] || [];
+    detailFields.value = fields.map((f) => ({
+      label: f.label,
+      value: (node as any)[f.key] ?? '-',
+    }));
+  }
+
+  function clearSelection() {
+    selectedNode.value = null;
+    detailFields.value = [];
+  }
+
+  // ==================== 缩放 ====================
+
+  function zoomIn() {
+    const cur = chartInstance?.getOption()?.series?.[0] as any;
+    const z = cur?.zoom ?? 1;
+    chartInstance?.setOption({ series: [{ zoom: Math.min(z * 1.3, 5) }] as any });
+  }
+
+  function zoomOut() {
+    const cur = chartInstance?.getOption()?.series?.[0] as any;
+    const z = cur?.zoom ?? 1;
+    chartInstance?.setOption({ series: [{ zoom: Math.max(z / 1.3, 0.3) }] as any });
+  }
+
+  function resetView() {
+    chartInstance?.setOption({ series: [{ zoom: 1, center: undefined }] as any });
+    clearSelection();
+  }
+
+  // ==================== 模式切换 ====================
+
+  /** 切换 绑定模式 / 布点模式;两种模式均保留悬浮高亮与单击详情 */
+  function setMode(m: TopoMode) {
+    mode.value = m;
+    armedSource.value = null;
+    bindVisible.value = false;
+    if (!chartInstance) return;
+    renderTopology(topologyData);
+    if (m === 'relation') {
+      message.info('双击连线解除关系;双击节点并选中另一节点以建立关系');
+    } else {
+      message.info('双击连线解绑或绑定测风装置');
+    }
+  }
+
+  // ==================== resize / dispose ====================
+
+  function handleResize() {
+    chartInstance?.resize();
+  }
+
+  function dispose() {
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+    window.removeEventListener('resize', handleResize);
+    chartInstance?.dispose();
+    chartInstance = null;
+  }
+
+  return {
+    chartRef,
+    mode,
+    setMode,
+    bindVisible,
+    bindPoint,
+    bindDeviceId,
+    confirmBind,
+    closeBindDialog,
+    selectedNode,
+    detailFields,
+    initChart,
+    loadTopology,
+    zoomIn,
+    zoomOut,
+    resetView,
+    clearSelection,
+    saveTopologyLayout,
+    dispose,
+  };
+}

+ 114 - 0
src/views/vent/monitorManager/windrectMonitor/topology/hooks/useTopologyLayout.ts

@@ -0,0 +1,114 @@
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+import { LAYOUT, LAYOUT_HEIGHT, ROOT_IN_ID, ROOT_OUT_ID } from '../windTopology.data';
+import { orderNodesByLevel } from './useTopologyOrdering';
+
+export interface LayoutResult {
+  /** 节点坐标(id → {x, y}) */
+  positions: Record<string, { x: number; y: number }>;
+}
+
+/** 第 idx 列(0=总进、1..7=lv0..lv6、8=未分级、9=总回)的 x 坐标(横向:从左到右) */
+const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
+
+/** level → 配置键:仅 0..6 视为已分级,NaN/越界统一映射为 'ungraded' */
+const levelKey = (lv?: number | null): number | 'ungraded' =>
+  lv !== undefined && lv !== null && Number.isFinite(lv) && lv >= 0 && lv <= 6 ? lv : 'ungraded';
+
+/** 该层级节点间距:按层级分别配置,未配置的层级回退 rowGapDefault */
+const rowGapFor = (lv?: number | null) => LAYOUT.rowGap[levelKey(lv) as any] ?? LAYOUT.rowGapDefault;
+
+/**
+ * 横向列式布局(从左到右):
+ *   固定列序 总进 → lv0..lv6 → 未分级 → 总回;
+ *   列间距(x 轴间隔)固定:x = margin + colGap × 列序;
+ *   节点间距(y 轴间隔)固定且按层级分别配置:每列取该层级 rowGap[level](未配置回退 rowGapDefault);
+ *   每列围绕画布水平中心线独立居中:start = centerY − spacing·(n−1)/2,
+ *   节点 y = start + 秩 × spacing;列内顺序由 useTopologyOrdering 交叉最小化排序
+ *   (median/barycenter 多轮扫描)决定;
+ *   节点自带有效 topologyX/topologyY(布局保存坐标,像素,非 0/null/'')时直接使用,跳过列布局。
+ */
+export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
+  // 横向布局下画布宽度不参与坐标计算(列 x 由 margin/colGap 固定),仅高度用于列内居中
+  const H = height < 100 ? LAYOUT_HEIGHT : height;
+
+  const positions: Record<string, { x: number; y: number }> = {};
+
+  const rootIn = data.nodes.find((n) => n.id === ROOT_IN_ID);
+  const rootOut = data.nodes.find((n) => n.id === ROOT_OUT_ID);
+  const points = data.nodes.filter((n) => n.category === 1);
+
+  // 按层级分组;未分级点位单独归列
+  const byLevel = new Map<number, TopoNodeData[]>();
+  const ungraded: TopoNodeData[] = [];
+  for (const p of points) {
+    const lv = Number(p.level);
+    if (Number.isFinite(lv) && lv >= 0 && lv <= 6) {
+      if (!byLevel.has(lv)) byLevel.set(lv, []);
+      byLevel.get(lv)!.push(p);
+    } else {
+      ungraded.push(p);
+    }
+  }
+
+  const centerY = H / 2;
+
+  // 列内排序:按交叉最小化排序结果重排(order 缺失的层保持输入序,防御性兜底)
+  const order = orderNodesByLevel(data);
+  const applyOrder = (col: TopoNodeData[], key: string) => {
+    const ids = order.get(key);
+    if (!ids || ids.length === 0 || col.length === 0) return;
+    const posById = new Map<string, number>();
+    ids.forEach((id, i) => posById.set(id, i));
+    const indexed = col.map((n, idx) => ({ n, idx }));
+    indexed.sort((p, q) => {
+      const pp = posById.get(p.n.id);
+      const pq = posById.get(q.n.id);
+      if (pp !== undefined && pq !== undefined) return pp - pq;
+      if (pp !== undefined) return -1;
+      if (pq !== undefined) return 1;
+      return p.idx - q.idx;
+    });
+    col.splice(0, col.length, ...indexed.map((x) => x.n));
+  };
+
+  // 列序:总进 → lv0..lv6 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
+  const lanes: TopoNodeData[][] = [];
+  lanes.push(rootIn ? [rootIn] : []);
+  for (let lv = 0; lv <= 6; lv++) {
+    const lane = byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : [];
+    applyOrder(lane, String(lv));
+    lanes.push(lane);
+  }
+  const ug = ungraded.length ? [...ungraded] : [];
+  applyOrder(ug, 'ungraded');
+  lanes.push(ug);
+  lanes.push(rootOut ? [rootOut] : []);
+
+  // 逐列赋坐标:x = margin + colGap × 列序;节点间距取该层级配置,每列独立垂直居中
+  lanes.forEach((lane, i) => {
+    const x = laneX(i);
+    const n = lane.length;
+    if (n === 0) return;
+    const spacing = rowGapFor(lane[0].level);
+    const start = centerY - (spacing * (n - 1)) / 2;
+    lane.forEach((p, k) => {
+      positions[p.id] = { x, y: start + k * spacing };
+    });
+  });
+
+  // 布局保存坐标覆盖:仅有效像素坐标生效。
+  // 排除 undefined/null/''/0 等后端默认无效值——否则 Number(null)=0、Number('')=0 会把
+  // 所有节点覆盖到 (0,0) 挤成一团。
+  const isValidSavedCoord = (v: unknown): boolean => {
+    if (v === undefined || v === null || v === '') return false;
+    const n = Number(v);
+    return Number.isFinite(n) && n !== 0;
+  };
+  for (const n of data.nodes) {
+    if (isValidSavedCoord(n.topologyX) && isValidSavedCoord(n.topologyY)) {
+      positions[n.id] = { x: Number(n.topologyX), y: Number(n.topologyY) };
+    }
+  }
+
+  return { positions };
+}

+ 233 - 0
src/views/vent/monitorManager/windrectMonitor/topology/hooks/useTopologyOrdering.ts

@@ -0,0 +1,233 @@
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+
+/**
+ * 通风网络拓扑层内排序:median / barycenter 多轮扫描法(Sugiyama 框架的 crossing reduction 阶段)。
+ *
+ * 背景:层级已由 level 字段固定为语义层(0=总进风 … 6=总回风),层内排序只影响相邻层
+ * 巷道连线的交叉数。二部交叉最小化(bipartite crossing minimization)为 NP-hard,
+ * 本模块采用工业图布局库(dagre/ELK)同款启发式:
+ *   1) 每层节点按"其相邻层邻居位置的 median(中位数)/ barycenter(均值)"计算关键值;
+ *   2) 左右多轮往返扫描(down/up sweep),保留历史交叉数最优解。
+ *
+ * 确定性保证:固定轮次(默认 20)+ 三元比较稳定排序(关键值、输入 index),
+ * 同一输入永远输出同一顺序,避免 resize / 刷新导致的布局抖动。
+ *
+ * 不参与排序、保持输入序(不影响交叉数的节点/边):
+ *   - 未分级层(level 无效);
+ *   - 跨层巷道边(两端 level 差 > 1)及其端点;
+ *   - 根边(地面 ↔ lv0 / lv6)与地面节点(单节点层)。
+ * 无任何相邻层边的节点关键值为 +Infinity:排在层尾,且彼此保持相对输入序。
+ */
+
+export interface OrderingOptions {
+  /** 邻居位置聚合方法:median=中位数(默认,抗离群,交叉 ≤ 3×最优 有理论保证)、barycenter=均值 */
+  method?: 'median' | 'barycenter';
+  /** 往返扫描轮数(固定值,保证确定性) */
+  sweepRounds?: number;
+}
+
+/** 参与排序的层级 key(0=总进风 1..5 现有层级 6=总回风),与 levelTextMap / computeLayout 分组一致 */
+const ORDER_KEYS = ['0', '1', '2', '3', '4', '5', '6'];
+
+/** 层级 key:0..6 有效 → 数字字符串;否则 'ungraded' */
+function levelKeyOf(p: TopoNodeData): string {
+  const lv = Number(p.level);
+  return Number.isFinite(lv) && lv >= 0 && lv <= 6 ? String(lv) : 'ungraded';
+}
+
+/**
+ * 构建相邻层(level 差恰为 1)巷道边表:
+ * key = "低层-高层"(如 '2-3'),value = 方向规范化的边数组 [低层节点 id, 高层节点 id]。
+ * 渲染方向(parent → child)不影响交叉判定,统一规范化为低层 → 高层。
+ */
+function buildPairEdges(data: TopologyData): Map<string, Array<[string, string]>> {
+  const levelById = new Map<string, number>();
+  for (const n of data.nodes) {
+    if (n.category !== 1) continue;
+    const lv = Number(n.level);
+    if (Number.isFinite(lv) && lv >= 0 && lv <= 6) levelById.set(n.id, lv);
+  }
+
+  const edgesByPair = new Map<string, Array<[string, string]>>();
+  for (const l of data.links) {
+    if (l.kind !== 'roadway') continue;
+    const sl = levelById.get(l.source);
+    const tl = levelById.get(l.target);
+    if (sl === undefined || tl === undefined) continue;
+    if (Math.abs(sl - tl) !== 1) continue;
+    const lo = Math.min(sl, tl);
+    const hi = Math.max(sl, tl);
+    const key = `${lo}-${hi}`;
+    if (!edgesByPair.has(key)) edgesByPair.set(key, []);
+    edgesByPair.get(key)!.push(sl < tl ? [l.source, l.target] : [l.target, l.source]);
+  }
+  return edgesByPair;
+}
+
+/** 层序数组 → 节点秩映射(nodeId → 层内 index);undefined(空层)返回空 map */
+function rankOf(order: string[] | undefined): Map<string, number> {
+  const rank = new Map<string, number>();
+  if (order) order.forEach((id, i) => rank.set(id, i));
+  return rank;
+}
+
+/**
+ * 相邻两层交叉数:edges 中每条边一端在 A 层、一端在 B 层;
+ * 两对边 (a1,b1)、(a2,b2) 交叉 ⇔ a1/a2 在 A 层秩的相对顺序与 b1/b2 在 B 层秩相反。
+ * O(E²) 直接统计(本系统每矿测风点 ≤ 数百,足够)。
+ */
+export function countCrossings(rankA: Map<string, number>, rankB: Map<string, number>, edges: Array<[string, string]>): number {
+  let total = 0;
+  for (let i = 0; i < edges.length; i++) {
+    const [a1, b1] = edges[i];
+    const ra1 = rankA.get(a1);
+    const rb1 = rankB.get(b1);
+    if (ra1 === undefined || rb1 === undefined) continue;
+    for (let j = i + 1; j < edges.length; j++) {
+      const [a2, b2] = edges[j];
+      const ra2 = rankA.get(a2);
+      const rb2 = rankB.get(b2);
+      if (ra2 === undefined || rb2 === undefined) continue;
+      if ((ra1 < ra2 && rb1 > rb2) || (ra1 > ra2 && rb1 < rb2)) total++;
+    }
+  }
+  return total;
+}
+
+/** 复用已有边表的全图总交叉数(4 个相邻层对 lv1-lv2 … lv4-lv5 交叉数之和) */
+function countTotalCrossingsWith(edgesByPair: Map<string, Array<[string, string]>>, order: Map<string, string[]>): number {
+  let total = 0;
+  for (let i = 0; i < ORDER_KEYS.length - 1; i++) {
+    const lo = ORDER_KEYS[i];
+    const hi = ORDER_KEYS[i + 1];
+    const edges = edgesByPair.get(`${lo}-${hi}`);
+    if (!edges || edges.length === 0) continue;
+    total += countCrossings(rankOf(order.get(lo)), rankOf(order.get(hi)), edges);
+  }
+  return total;
+}
+
+/** 全图总交叉数:order 为 orderNodesByLevel 返回的层序(key '0'..'6') */
+export function countTotalCrossings(data: TopologyData, order: Map<string, string[]>): number {
+  return countTotalCrossingsWith(buildPairEdges(data), order);
+}
+
+/** 深拷贝层序(仅复制参与排序的层) */
+function snapshotOrder(order: Map<string, string[]>): Map<string, string[]> {
+  const copy = new Map<string, string[]>();
+  for (const [k, arr] of order) copy.set(k, [...arr]);
+  return copy;
+}
+
+/**
+ * 单层重排:目标层节点按"其在 neighbor 层的邻居位置"的 median/barycenter 升序稳定排序。
+ * 无邻居的节点关键值 +Infinity → 排层尾且彼此保持相对输入序。
+ */
+function sweepLayer(
+  current: Map<string, string[]>,
+  edges: Array<[string, string]> | undefined,
+  targetKey: string,
+  neighborKey: string,
+  isTargetHigh: boolean,
+  method: 'median' | 'barycenter'
+): void {
+  const target = current.get(targetKey);
+  if (!target || target.length <= 1 || !edges || edges.length === 0) return;
+  const neighborRank = rankOf(current.get(neighborKey));
+
+  // 目标层节点 → 其邻居(neighbor 层)位置集合(边已规范化为 [低层, 高层])
+  const neighborPos = new Map<string, number[]>();
+  for (const [loId, hiId] of edges) {
+    const tId = isTargetHigh ? hiId : loId;
+    const nId = isTargetHigh ? loId : hiId;
+    const r = neighborRank.get(nId);
+    if (r === undefined) continue;
+    if (!neighborPos.has(tId)) neighborPos.set(tId, []);
+    neighborPos.get(tId)!.push(r);
+  }
+
+  const keyOf = (id: string): number => {
+    const arr = neighborPos.get(id);
+    if (!arr || arr.length === 0) return Infinity;
+    if (method === 'barycenter') {
+      let sum = 0;
+      for (const v of arr) sum += v;
+      return sum / arr.length;
+    }
+    arr.sort((x, y) => x - y);
+    const m = Math.floor(arr.length / 2);
+    return arr.length % 2 === 1 ? arr[m] : (arr[m - 1] + arr[m]) / 2;
+  };
+
+  const indexed = target.map((id, idx) => ({ id, idx }));
+  indexed.sort((p, q) => {
+    const kp = keyOf(p.id);
+    const kq = keyOf(q.id);
+    if (kp !== kq) return kp - kq;
+    return p.idx - q.idx;
+  });
+  current.set(
+    targetKey,
+    indexed.map((p) => p.id)
+  );
+}
+
+/**
+ * 层内排序主入口:返回各层有序节点 id(key:'0'..'6' 与 'ungraded')。
+ * - 参与排序层(lv0..lv6):以输入序(data.nodes 顺序,即后端返回序)为初始序,
+ *   sweep 后返回历史交叉数最优解;
+ * - 未分级层:始终返回输入序(不参与扫描);
+ * - 空层:返回空数组。
+ */
+export function orderNodesByLevel(data: TopologyData, opts?: OrderingOptions): Map<string, string[]> {
+  const method = opts?.method ?? 'median';
+  const sweepRounds = opts?.sweepRounds ?? 20;
+
+  // 1) 按输入序分层
+  const layers = new Map<string, string[]>();
+  for (const n of data.nodes) {
+    if (n.category !== 1) continue;
+    const key = levelKeyOf(n);
+    if (!layers.has(key)) layers.set(key, []);
+    layers.get(key)!.push(n.id);
+  }
+
+  const edgesByPair = buildPairEdges(data);
+
+  // 2) 初始层序(仅参与层)与历史最优记录
+  const current = new Map<string, string[]>();
+  for (const key of ORDER_KEYS) {
+    current.set(key, layers.get(key) ? [...layers.get(key)!] : []);
+  }
+  let best = snapshotOrder(current);
+  let bestCross = countTotalCrossingsWith(edgesByPair, current);
+  const evalTotal = () => {
+    const c = countTotalCrossingsWith(edgesByPair, current);
+    if (c < bestCross) {
+      bestCross = c;
+      best = snapshotOrder(current);
+    }
+  };
+
+  // 3) 多轮往返扫描:down(上→下,用低层位置重排高层)+ up(下→上,用高层位置重排低层)
+  for (let round = 0; round < sweepRounds; round++) {
+    for (let i = 0; i < ORDER_KEYS.length - 1; i++) {
+      const key = `${ORDER_KEYS[i]}-${ORDER_KEYS[i + 1]}`;
+      sweepLayer(current, edgesByPair.get(key), ORDER_KEYS[i + 1], ORDER_KEYS[i], true, method);
+      evalTotal();
+    }
+    for (let i = ORDER_KEYS.length - 2; i >= 0; i--) {
+      const key = `${ORDER_KEYS[i]}-${ORDER_KEYS[i + 1]}`;
+      sweepLayer(current, edgesByPair.get(key), ORDER_KEYS[i], ORDER_KEYS[i + 1], false, method);
+      evalTotal();
+    }
+  }
+
+  // 4) 合并输出:参与层用历史最优,未分级层用输入序
+  const result = new Map<string, string[]>();
+  result.set('ungraded', layers.get('ungraded') ? [...layers.get('ungraded')!] : []);
+  for (const key of ORDER_KEYS) {
+    result.set(key, best.get(key) ? [...best.get(key)!] : []);
+  }
+  return result;
+}

+ 184 - 0
src/views/vent/monitorManager/windrectMonitor/topology/index.vue

@@ -0,0 +1,184 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<template>
+  <div class="p-4 device-manager-box">
+    <a-row>
+      <!-- 测风地点管理 -->
+      <a-col :span="16">
+        <BasicTable @register="registerTable" :rowSelection="rowSelection">
+          <template #tableTitle>
+            <a-button type="primary" preIcon="ant-design:plus-outlined" @click="handleCreate"> 新增</a-button>
+            <a-dropdown v-if="selectedRowKeys.length > 0">
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item key="1" @click="batchHandleDelete">
+                    <Icon icon="ant-design:delete-outlined" />
+                    删除
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </template>
+          <template #action="{ record }">
+            <TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
+          </template>
+        </BasicTable>
+      </a-col>
+
+      <!-- 测风装置管理(只读) -->
+      <a-col :span="8">
+        <BasicTable @register="registerDeviceTable" />
+      </a-col>
+    </a-row>
+
+    <!-- 新增/编辑测风地点弹窗 -->
+    <BasicModal @register="registerModal" :width="600" :min-height="500" centered :title="modalTitle" @ok="handleSubmit" destroy-on-close>
+      <BasicForm @register="registerForm" />
+    </BasicModal>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { BasicTable, TableAction, ActionItem } from '/@/components/Table';
+  import { BasicForm, useForm } from '/@/components/Form';
+  import { BasicModal, useModal } from '/@/components/Modal';
+  import { Icon } from '/@/components/Icon';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { useListPage } from '/@/hooks/system/useListPage';
+  import { areaColumns, areaSearchFormSchema, areaFormSchema, fallbackDeviceColumns } from './windPointManage.data';
+  import { getWindMonitorAreaList, saveWindMonitorArea, deleteWindMonitorArea, getWindrectDeviceList } from './windTopology.api';
+
+  const { createMessage } = useMessage();
+
+  /** 当前页签:默认测风地点管理 */
+
+  // ==================== 测风地点管理 ====================
+  // 列表页面公共参数、方法
+  const { tableContext } = useListPage({
+    tableProps: {
+      title: '测风地点列表',
+      api: getWindMonitorAreaList,
+      columns: areaColumns,
+      size: 'small',
+      // 接口无分页(返回全部记录),与“菜单管理”一致关闭分页器
+      pagination: false,
+      formConfig: {
+        schemas: areaSearchFormSchema,
+      },
+      showTableSetting: false,
+      actionColumn: {
+        width: 150,
+      },
+      beforeFetch: (params) => {
+        return Object.assign({ column: 'createTime', order: 'desc' }, params);
+      },
+    },
+  });
+  // 注册table数据
+  const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
+
+  // 新增/编辑弹窗
+  const isUpdate = ref(false);
+  const modalTitle = computed(() => (isUpdate.value ? '编辑测风地点' : '新增测风地点'));
+
+  const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
+    schemas: areaFormSchema,
+    showActionButtonGroup: false,
+    labelCol: { xs: { span: 24 }, sm: { span: 4 } },
+    wrapperCol: { xs: { span: 24 }, sm: { span: 18 } },
+  });
+
+  const [registerModal, { openModal, closeModal, setModalProps }] = useModal();
+
+  /** 新增 */
+  function handleCreate() {
+    isUpdate.value = false;
+    openModal(true, {});
+    resetFields();
+  }
+
+  /** 编辑 */
+  async function handleEdit(record: Recordable) {
+    isUpdate.value = true;
+    openModal(true, {});
+    await resetFields();
+    setFieldsValue({ ...record });
+  }
+
+  /** 提交保存 */
+  async function handleSubmit() {
+    try {
+      const values = await validate();
+      setModalProps({ confirmLoading: true });
+      await saveWindMonitorArea(values);
+      closeModal();
+      reload();
+      createMessage.success('保存成功');
+    } catch {
+      // validate 未通过时不处理
+    } finally {
+      setModalProps({ confirmLoading: false });
+    }
+  }
+
+  /** 单条删除 */
+  async function handleDelete(record: Recordable) {
+    await deleteWindMonitorArea({ ids: record.id });
+    createMessage.success('删除成功');
+    reload();
+  }
+
+  /** 批量删除 */
+  async function batchHandleDelete() {
+    await deleteWindMonitorArea({ ids: selectedRowKeys.value.join(',') });
+    createMessage.success('删除成功');
+    selectedRowKeys.value = [];
+    reload();
+  }
+
+  /** 操作栏 */
+  function getTableAction(record: Recordable): ActionItem[] {
+    return [
+      {
+        label: '编辑',
+        onClick: handleEdit.bind(null, record),
+      },
+    ];
+  }
+
+  /** 下拉操作栏 */
+  function getDropDownAction(record: Recordable): ActionItem[] {
+    return [
+      {
+        label: '删除',
+        color: 'error',
+        popConfirm: {
+          title: '是否确认删除',
+          confirm: handleDelete.bind(null, record),
+        },
+      },
+    ];
+  }
+
+  // ==================== 测风装置管理(只读) ====================
+  const { tableContext: deviceContext } = useListPage({
+    tableProps: {
+      title: '测风装置列表',
+      api: getWindrectDeviceList,
+      columns: fallbackDeviceColumns,
+      size: 'small',
+      showActionColumn: false,
+      useSearchForm: false,
+      showTableSetting: false,
+      // 与 windrectMonitor/components/modalTable.vue 一致:合并 readData 子对象
+      afterFetch: (list) => (list || []).map((d: Recordable) => Object.assign({}, d, (d as any).readData)),
+    },
+  });
+  const [registerDeviceTable] = deviceContext;
+</script>
+
+<style lang="less" scoped>
+  .device-manager-box {
+    height: 100%;
+  }
+</style>

+ 125 - 0
src/views/vent/monitorManager/windrectMonitor/topology/windPointManage.data.ts

@@ -0,0 +1,125 @@
+import type { BasicColumn, FormSchema } from '/@/components/Table';
+import { h } from 'vue';
+import { levelTextMap } from './windTopology.data';
+import { getWindrectDeviceList } from './windTopology.api';
+
+/** 层级选项(0=总进风 … 6=总回风) */
+export const levelOptions = Object.keys(levelTextMap).map((k) => ({ label: levelTextMap[Number(k)], value: Number(k) }));
+
+/** 测风地点列表列 */
+export const areaColumns: BasicColumn[] = [
+  {
+    title: '区域名称',
+    dataIndex: 'name',
+    width: 150,
+    fixed: 'left',
+  },
+  {
+    title: '层级',
+    dataIndex: 'level',
+    width: 100,
+    customRender: ({ text }) => {
+      const map: Record<number, string> = {
+        0: '进风井',
+        1: '矿井进风',
+        2: '采区进风',
+        3: '采区用风',
+        4: '采区回风',
+        5: '矿井回风',
+        6: '回风井',
+      };
+      return map[text] || text;
+    },
+  },
+  {
+    title: '需风量', // (m³/min)
+    dataIndex: 'airVolume',
+    width: 100,
+  },
+  {
+    title: '风量', // (m³/min)
+    dataIndex: 'm3Volume',
+    width: 100,
+  },
+  // {
+  //   title: '规程值',
+  //   dataIndex: 'regulationId',
+  //   width: 90,
+  // },
+  {
+    title: '测风设备',
+    dataIndex: 'deviceId',
+    width: 100,
+    customRender({ record }) {
+      return h('span', (record as any).deviceId ? '已绑定' : '未绑定');
+    },
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    width: 150,
+  },
+];
+
+/** 测风地点搜索表单 */
+export const areaSearchFormSchema: FormSchema[] = [
+  // {
+  //   label: '名称',
+  //   field: 'name',
+  //   component: 'JInput',
+  //   colProps: { span: 6 },
+  // },
+  {
+    label: '层级',
+    field: 'level',
+    component: 'Select',
+    componentProps: { placeholder: '请选择层级', options: levelOptions },
+    colProps: { span: 6 },
+  },
+];
+
+/** 测风地点新增/编辑表单 */
+export const areaFormSchema: FormSchema[] = [
+  { label: 'id', field: 'id', component: 'Input', show: false },
+  {
+    label: '测风点名称',
+    field: 'name',
+    required: true,
+    component: 'Input',
+    componentProps: { placeholder: '请输入测风点名称' },
+  },
+  {
+    label: '层级',
+    field: 'level',
+    required: true,
+    component: 'Select',
+    componentProps: { placeholder: '请选择层级', options: levelOptions },
+  },
+  {
+    label: '测风设备',
+    field: 'deviceId',
+    component: 'ApiSelect',
+    componentProps: {
+      api: getWindrectDeviceList,
+      labelField: 'strinstallpos',
+      valueField: 'deviceID',
+      params: { devicetype: 'windrect', pagetype: 'normal' },
+      placeholder: '请选择测风设备',
+      allowClear: true,
+    },
+  },
+];
+
+/** 测风装置表格回退列(服务端列不可用时使用) */
+export const fallbackDeviceColumns: BasicColumn[] = [
+  {
+    title: '安装位置',
+    dataIndex: 'strinstallpos',
+    width: 200,
+  },
+  {
+    title: '状态',
+    dataIndex: 'warnLevel_str',
+    width: 100,
+  },
+];

+ 87 - 0
src/views/vent/monitorManager/windrectMonitor/topology/windTopology.api.ts

@@ -0,0 +1,87 @@
+import { defHttp } from '/@/utils/http/axios';
+import type { WindMonitorAreaNode, WindMonitorAreaRelationNode, TopologyBundle, WindrectNode } from './windTopology.data';
+import { buildTopologyBundle } from './windTopology.data';
+import { list as listWindrect } from '../windrect.api';
+
+enum Api {
+  getWindMonitorAreaList = '/monitor/windMonitorArea/getWindMonitorAreaList',
+  addWindMonitorArea = '/monitor/windMonitorArea/addWindMonitorArea',
+  updateWindMonitorArea = '/monitor/windMonitorArea/updateWindMonitorArea',
+  deleteWindMonitorArea = '/monitor/windMonitorArea/deleteWindMonitorArea',
+  updateWindMonitorAreaTopology = '/monitor/windMonitorArea/updateWindMonitorAreaTopology',
+  getWindMonitorAreaRelation = '/monitor/windMonitorArea/getWindMonitorAreaRelation',
+  addWindMonitorAreaRelation = '/monitor/windMonitorArea/addWindMonitorAreaRelation',
+  deleteWindMonitorAreaRelation = '/monitor/windMonitorArea/deleteWindMonitorAreaRelation',
+}
+
+/**
+ * 获取测风网络拓扑全量数据(接口获取 → 代码处理 → 返回完整数据供消费):
+ *   getWindMonitorAreaRelation -> 测风地点列表 + 巷道关系列表(无参数,返回全系统)
+ *   测风装置列表(/monitor/device,devicetype=windrect,用于解析已绑定设备显示名)
+ * 所有接口并行请求(allSettled),单个失败不影响整体;整体异常时返回备用空数据
+ * (总进地面节点 + 空列表),保证拓扑图总能绘制。
+ */
+export const getTopologyData = async (): Promise<TopologyBundle> => {
+  try {
+    // 兼容两种响应结构:分页 { records } 或裸数组
+    const toList = (res: any): any[] => (Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : []);
+
+    const [relRes, windrectRes] = await Promise.allSettled([
+      defHttp.post({ url: Api.getWindMonitorAreaRelation }),
+      listWindrect({ devicetype: 'windrect', pagetype: 'normal' }),
+    ]);
+    // 单个接口失败不影响另一个(避免整体空白);失败时记录原因便于定位
+    const relData = relRes.status === 'fulfilled' ? (relRes.value as any) || {} : (console.error('获取测风点关系失败:', relRes.reason), {});
+    const areaList = Array.isArray(relData.windMonitorAreaList) ? (relData.windMonitorAreaList as WindMonitorAreaNode[]) : [];
+    const relationList = Array.isArray(relData.windMonitorAreaRelationList)
+      ? (relData.windMonitorAreaRelationList as WindMonitorAreaRelationNode[])
+      : [];
+    // 测风装置:/monitor/device 形如 { msgTxt: [{ datalist }] }
+    const deviceData =
+      windrectRes.status === 'fulfilled' ? (windrectRes.value as any) || {} : (console.error('获取测风装置失败:', windrectRes.reason), {});
+    const datalist = Array.isArray(deviceData?.msgTxt?.[0]?.datalist) ? deviceData.msgTxt[0].datalist : [];
+    const windrectList = toList(datalist) as WindrectNode[];
+
+    return buildTopologyBundle(areaList, relationList, windrectList);
+  } catch (e) {
+    // 备用空数据兜底:保证拓扑图(总进地面节点)在接口异常时仍可绘制
+    console.error('拓扑数据组装失败,使用空数据兜底:', e);
+    return buildTopologyBundle([], [], []);
+  }
+};
+
+/** 查询测风点区域列表(无分页,返回全部记录,管理表格用) */
+export const getWindMonitorAreaList = (params?: any) => defHttp.post({ url: Api.getWindMonitorAreaList, params });
+
+/** 编辑测风地点(绑定/解绑测风装置通过字段:id=测风地点主键、deviceId=绑定测风装置 id) */
+export const updateWindMonitorArea = (params?: any) => defHttp.post({ url: Api.updateWindMonitorArea, params });
+
+/** 新增或编辑测风地点(根据是否有 id 自动切换) */
+export const saveWindMonitorArea = (params?: any) => {
+  const url = params?.id ? Api.updateWindMonitorArea : Api.addWindMonitorArea;
+  return defHttp.post({ url, params });
+};
+
+/** 删除测风地点(含关联关系,ids 必填,逗号分隔) */
+export const deleteWindMonitorArea = (params?: any) => defHttp.post({ url: Api.deleteWindMonitorArea, params }, { joinParamsToUrl: true });
+
+/** 批量更新测风地点拓扑坐标(RequestBody 数组:[{ id, topologyX, topologyY }]) */
+export const updateWindMonitorAreaTopology = (params?: any) => defHttp.post({ url: Api.updateWindMonitorAreaTopology, params });
+
+/** 绑定测风点关联关系(RequestBody:parentId/childId;双击节点+单击节点关联时调用) */
+export const addWindMonitorAreaRelation = (params?: any) => defHttp.post({ url: Api.addWindMonitorAreaRelation, params });
+
+/** 删除测风点关联关系(ids 传关系主键 id,逗号分隔/单 id;双击连线解除关系时调用) */
+export const deleteWindMonitorAreaRelation = (params?: any) =>
+  defHttp.post({ url: Api.deleteWindMonitorAreaRelation, params }, { joinParamsToUrl: true });
+
+/**
+ * 查询测风装置列表(/monitor/device,devicetype=windrect)。
+ * /monitor/device 响应形如 { msgTxt: [{ datalist }] },此处解包为数组供 ApiSelect/下拉使用。
+ */
+export const getWindrectDeviceList = async (params?: any): Promise<any[]> => {
+  const res = await listWindrect({ devicetype: 'windrect', pagetype: 'normal', ...params });
+  if (Array.isArray(res)) return res as WindrectNode[];
+  if (Array.isArray(res?.msgTxt?.[0]?.datalist)) return res.msgTxt[0].datalist as WindrectNode[];
+  return [];
+};

+ 368 - 0
src/views/vent/monitorManager/windrectMonitor/topology/windTopology.data.ts

@@ -0,0 +1,368 @@
+import type { EChartsOption } from 'echarts';
+
+// ==================== 节点分类定义 ====================
+
+export interface CategoryDef {
+  name: string; // 分类名称
+  color: string; // 节点颜色
+  symbol: string; // ECharts 图形:circle / rect / diamond / pin
+  symbolSize: number; // 节点大小
+}
+
+/** 2 类节点:地面(前端生成,只读)、测风地点 */
+export const categories: CategoryDef[] = [
+  { name: '地面', color: '#722ed1', symbol: 'diamond', symbolSize: 26 },
+  { name: '测风地点', color: '#fa8c16', symbol: 'circle', symbolSize: 30 },
+];
+
+/** 测风地点统一节点样式:浅灰圆点(层级/类型区分转移到连线颜色;浅色在深色拓扑底上更清晰) */
+export const POINT_COLOR = '#c8d3de';
+export const POINT_SIZE = 16;
+
+// ==================== 测风地点层级映射 ====================
+
+/** 测风地点通风类型层级(level 字段语义),用于按层级分列绘制拓扑。
+ *  注意:与源项目不同,本模块按 WindMonitorAreaController 文档的 level 语义:
+ *  0-总进风, 1-矿井进风, 2-采区进风, 3-采区用风, 4-采区回风, 5-矿井回风, 6-总回风 */
+export const levelTextMap: Record<number, string> = {
+  0: '总进风',
+  1: '矿井进风',
+  2: '采区进风',
+  3: '采区用风',
+  4: '采区回风',
+  5: '矿井回风',
+  6: '总回风',
+};
+
+/** 测风地点层级配色(7 级互不混淆,避开地面紫 #722ed1),用于按层级区分巷道连线颜色 */
+export const levelColorMap: Record<number, string> = {
+  0: '#52c41a', // 总进风
+  1: '#f5222d', // 矿井进风
+  2: '#fa8c16', // 采区进风
+  3: '#fadb14', // 采区用风
+  4: '#13c2c2', // 采区回风
+  5: '#eb2f96', // 矿井回风
+  6: '#1677ff', // 总回风
+};
+
+// ==================== 拓扑布局参数 ====================
+
+/** 拓扑布局参数(px)。
+ *  横向布局(列从左到右):层级层变为从左到右的列,因此:
+ *    colGap   —— 相邻层级列之间的间距(x 方向);
+ *    rowGap[level] —— 同一层级内节点之间的间距(y 方向);
+ *    margin   —— 画布左边距。
+ *  保存布局时坐标按 snapGrid 取整对齐。 */
+export const LAYOUT = {
+  /** 画布左边距(总进所在 x 起点) */
+  margin: 50,
+  /** 层级列间距(x 轴间隔,固定) */
+  colGap: 260,
+  /** 保存布局时坐标对齐网格大小(px):保存时 topologyX/topologyY 按此取整 */
+  snapGrid: 50,
+  /** 各层级(y 轴)节点间距,未配置的层级回退 rowGapDefault */
+  rowGap: { 0: 300, 1: 300, 2: 300, 3: 200, 4: 300, 5: 300, 6: 300, ungraded: 200 } as Record<number, number> & { ungraded: number },
+  /** 未在 rowGap 中配置的层级使用的节点间距(px) */
+  rowGapDefault: 200,
+};
+
+/** 画布宽度占位(chart 100% 容器,此处设参考值;过小时兜底使用) */
+export const LAYOUT_WIDTH = 700;
+
+/** 画布高度占位(横向布局下用于列内垂直居中;容器高度过小时兜底使用) */
+export const LAYOUT_HEIGHT = 700;
+
+// ==================== ECharts graph 基础配置 ====================
+
+export function createGraphOption(): EChartsOption {
+  return {
+    series: [
+      {
+        type: 'graph',
+        layout: 'none', // 固定位置,由 hooks/useTopologyLayout.computeLayout 分配坐标
+        roam: true,
+        roamTrigger: 'global',
+        draggable: true, // 节点固定(fixed=true),roam 用于平移缩放
+        categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
+        // 边统一带风流方向箭头(进风→上、回风→下,由 roadway 边 source→target 决定)
+        edgeSymbol: ['none', 'arrow'],
+        edgeSymbolSize: [0, 10],
+        // 巷道连线中点标注:常态仅显示名称(midLabel),高亮(emphasis)显示名称及布点信息(midLabelFull);
+        // 名称行 + 圆点与已绑定布点名;未绑定时第三行输出浅灰占位圆点
+        edgeLabel: {
+          show: true,
+          position: 'middle',
+          verticalAlign: 'top',
+          opacity: 0.4,
+          width: 100,
+          overflow: 'truncate',
+          // midLabel/midLabelFull 由渲染层按 rich 标记生成:常态 {name|...};高亮 {name|...}\n{dot|● }{device|...} 或 {dotEmpty|● }
+          formatter: (p: any) => p?.data?.midLabel || '',
+          rich: {
+            name: { fontSize: 14, color: 'rgba(255,255,255,0.95)', fontWeight: 'bold' },
+            dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
+            device: { fontSize: 12, color: 'rgba(255,255,255,0.95)', lineHeight: 18 },
+            dotEmpty: { fontSize: 12, color: '#8ba3b0', lineHeight: 18 },
+          },
+        },
+        label: {
+          show: true,
+          position: 'bottom',
+          fontSize: 11,
+          color: 'rgba(255,255,255,0.92)',
+          formatter: (p: any) => p.name || '',
+        },
+        lineStyle: { color: '#8c8c8c', curveness: 0, width: 2, opacity: 0.7 },
+        // 悬浮高亮:高亮时连线标注切换为"名称 + 布点信息"(emphasis.edgeLabel.formatter 读 midLabelFull)
+        emphasis: {
+          focus: 'series',
+          lineStyle: { width: 3, opacity: 1 },
+          edgeLabel: {
+            show: true,
+            opacity: 1,
+            formatter: (p: any) => p?.data?.midLabelFull || '',
+          },
+        },
+        nodes: [],
+        links: [],
+        tooltip: {
+          show: true,
+        },
+      },
+    ],
+  };
+}
+
+// ==================== 节点详情字段 ====================
+
+export interface DetailField {
+  label: string;
+  key: string;
+}
+
+export const nodeDetailFields: Record<string, DetailField[]> = {
+  地面: [{ label: '节点名称', key: 'name' }],
+  测风地点: [{ label: '层级', key: 'levelText' }],
+};
+
+// ==================== 拓扑数据类型 ====================
+
+export interface TopoNodeData {
+  id: string;
+  /** 原始实体 id(WindMonitorArea 主键),关联操作时使用 */
+  rawId?: string;
+  name: string;
+  category: number; // categories 索引
+  parentId?: string | null;
+  isLeaf?: boolean;
+  isLeafText?: string;
+  /** 测风地点通风类型层级(0=总进风 … 6=总回风),用于按层级分列 */
+  level?: number;
+  /** 层级显示名(levelTextMap 映射) */
+  levelText?: string;
+  /** 绑定测风装置(设备)id */
+  deviceId?: string;
+  /** 布局保存坐标(用户拖动后保存的 ECharts 画布像素坐标,加载时优先使用) */
+  topologyX?: number | string;
+  topologyY?: number | string;
+  [key: string]: any;
+}
+
+export interface TopoLinkData {
+  source: string;
+  target: string;
+  label?: string;
+  /** 连线类型:roadway=巷道连线(父巷道 → 子巷道 直线,或 地面→lv0 / lv6→地面 根边) */
+  kind?: 'roadway';
+  /** 连线子测风地点 id(roadway 边指向子级点位) */
+  pointId?: string;
+  /**
+   * 连线主要数据点位 id:进风/用风连线取子节点、回风连线取父节点。
+   * 用于连线中点标注(名称/已绑定布点名)、布点模式绑定/解绑目标。
+   */
+  mainPointId?: string;
+  /** 对应巷道关系 id(windMonitorAreaRelationList 项主键),双击连线解除关系时传入;根边无 */
+  relationId?: string;
+  /** 风流方向:intake=进风(上→下)/ return=回风(下→上) */
+  flow?: 'intake' | 'return';
+}
+
+export interface TopologyData {
+  nodes: TopoNodeData[];
+  links: TopoLinkData[];
+}
+
+// ==================== API 响应类型 ====================
+
+/** API -> WindMonitorArea(测风地点,level 为通风类型层级) */
+export interface WindMonitorAreaNode {
+  id: string;
+  name: string;
+  level: number; // 0=总进风 1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风 6=总回风
+  deviceId?: string;
+  xCoordinate?: string;
+  yCoordinate?: string;
+  /** 布局保存坐标(用户拖动后保存的 ECharts 画布像素坐标) */
+  topologyX?: number | string;
+  topologyY?: number | string;
+  createTime?: string;
+  updateTime?: string;
+  [key: string]: any;
+}
+
+/** API -> 测风装置(/monitor/device 返回,字段以实际接口为准) */
+export interface WindrectNode {
+  id: string;
+  deviceID?: string;
+  deviceCode?: string;
+  devicePos?: string;
+  strname?: string;
+  strinstallpos?: string;
+  stationname?: string;
+  mineName?: string;
+  status?: number;
+  createTime?: string;
+  [key: string]: any;
+}
+
+/** API -> WindMonitorAreaRelation(巷道关系:一条巷道 = 父级别点 → 子级别点) */
+export interface WindMonitorAreaRelationNode {
+  id: string;
+  parentId: string;
+  childId: string;
+  createTime?: string;
+  updateTime?: string;
+  [key: string]: any;
+}
+
+/** 关系数组元素:一条巷道(含关系主键 id,供删除),parent 为主要节点 */
+export interface AreaRelation {
+  id: string;
+  parent: WindMonitorAreaNode;
+  child: WindMonitorAreaNode;
+}
+
+/** 前端生成根节点 id:地面(总进 / 总回) */
+export const ROOT_IN_ID = 'root:in';
+export const ROOT_OUT_ID = 'root:out';
+
+/**
+ * 拓扑全量数据(getTopologyData 返回,供消费方直接使用):
+ * 接口获取数据 → 代码处理(关系/地面节点模拟)→ 组装为一份完整数据。
+ */
+export interface TopologyBundle {
+  /** 可直接渲染的拓扑数据(含总进/总回地面节点) */
+  topology: TopologyData;
+  /** 测风装置全量列表(用于解析已绑定设备显示名写入连线标注) */
+  windrectList: WindrectNode[];
+}
+
+/**
+ * 由 getWindMonitorAreaRelation 返回的 windMonitorAreaList + windMonitorAreaRelationList 生成关系数组。
+ * 每个元素 { id, parent, child } 描述一条巷道(父子节点信息),parent 为主要节点;
+ * 过滤 parent/child 引用缺失(脏数据)的关系。
+ */
+export function buildRelationArray(areas: WindMonitorAreaNode[], relations: WindMonitorAreaRelationNode[]): AreaRelation[] {
+  const byId = new Map<string, WindMonitorAreaNode>();
+  for (const a of areas) {
+    if (!a.id) continue;
+    byId.set(String(a.id), a);
+  }
+  const result: AreaRelation[] = [];
+  for (const r of relations) {
+    if (!r.id || !r.parentId || !r.childId) continue;
+    const parent = byId.get(String(r.parentId));
+    const child = byId.get(String(r.childId));
+    if (parent && child) result.push({ id: r.id, parent, child });
+  }
+  return result;
+}
+
+/**
+ * 以关系数组为数据源生成 TopologyData(每条巷道 = 父 → 子 单条直线连线):
+ * 节点:1) 总进/总回地面节点(前端生成);2) 巷道点(windMonitorAreaList 全部记录,按 level 分列)。
+ * 边:1) 每条巷道 parent → child(roadway,携带 relationId 供删除、pointId 供标注);
+ *     2) 地面(总进)→ lv0 巷道点、lv6 巷道点 → 地面(总回)(根边,无 relationId)。
+ * 节点 id 前缀(point:)保证全局唯一,过滤缺失 id 并去重。
+ */
+export function transformToTopologyData(areas: WindMonitorAreaNode[], relations: AreaRelation[]): TopologyData {
+  const prefix = (cat: string, id: any) => (id === undefined || id === null || id === '' ? '' : `${cat}:${id}`);
+
+  // 巷道点:过滤缺失 id + 去重
+  const pointById = new Map<string, WindMonitorAreaNode>();
+  for (const a of areas) {
+    if (!a.id) continue;
+    const pid = prefix('point', a.id);
+    if (!pointById.has(pid)) pointById.set(pid, a);
+  }
+
+  const links: TopoLinkData[] = [];
+  // 巷道边:每条关系一条 父 → 子 直线(relationId 指向关系主键、pointId 指向子巷道点)
+  for (const rel of relations) {
+    const parentId = prefix('point', rel.parent.id);
+    const childId = prefix('point', rel.child.id);
+    if (!pointById.has(parentId) || !pointById.has(childId)) continue;
+    // 流向:子节点 level ≤3 进风、>3 回风;主要数据点位:进风/用风取子节点、回风取父节点
+    const isIntake = Number(rel.child.level) <= 3;
+    const flow: 'intake' | 'return' = isIntake ? 'intake' : 'return';
+    const mainPointId = isIntake ? childId : parentId;
+    links.push({ source: parentId, target: childId, kind: 'roadway', flow, pointId: childId, mainPointId, relationId: rel.id });
+  }
+
+  // 动态根边(无关系 id,前端按层级生成):
+  // 进风根边:总进 → 最低有效进风层(优先 lv0 总进风,缺失回退 lv1);
+  // 回风根边:最高有效回风层(优先 lv6 总回风,缺失回退 lv5)→ 总回。
+  // 其余层级间的连接由巷道关系数据驱动。
+  const pointLevels = new Set<number>();
+  for (const a of pointById.values()) pointLevels.add(Number(a.level));
+  const intakeRootLevel = pointLevels.has(0) ? 0 : 1;
+  const returnRootLevel = pointLevels.has(6) ? 6 : 5;
+
+  for (const [pid, a] of pointById) {
+    if (Number(a.level) === intakeRootLevel) {
+      links.push({ source: ROOT_IN_ID, target: pid, kind: 'roadway', flow: 'intake', pointId: pid, mainPointId: pid });
+    }
+  }
+  for (const [pid, a] of pointById) {
+    if (Number(a.level) === returnRootLevel) {
+      links.push({ source: pid, target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: pid, mainPointId: pid });
+    }
+  }
+
+  const nodes: TopoNodeData[] = [
+    // 总进/总回地面(前端生成,只读)
+    { id: ROOT_IN_ID, name: '地面', category: 0 },
+    { id: ROOT_OUT_ID, name: '地面', category: 0 },
+    // 巷道点
+    ...Array.from(pointById.entries()).map(([pid, a]) => ({
+      id: pid,
+      rawId: a.id,
+      name: a.name,
+      category: 1,
+      deviceId: a.deviceId,
+      level: a.level,
+      levelText: levelTextMap[Number(a.level)] || '',
+      topologyX: a.topologyX,
+      topologyY: a.topologyY,
+    })),
+  ];
+
+  return { nodes, links };
+}
+
+/**
+ * 由各接口原始数据组装拓扑全量数据(供 getTopologyData 调用):
+ * 生成巷道关系数组 → 拓扑数据(含总进/总回地面节点)。
+ * 任何列表为空都返回完整结构(总进地面节点 + 空列表),保证消费方总能绘制拓扑图。
+ */
+export function buildTopologyBundle(
+  windMonitorAreaList: WindMonitorAreaNode[],
+  windMonitorAreaRelationList: WindMonitorAreaRelationNode[],
+  windrectList: WindrectNode[]
+): TopologyBundle {
+  const relations = buildRelationArray(windMonitorAreaList, windMonitorAreaRelationList);
+  return {
+    topology: transformToTopologyData(windMonitorAreaList, relations),
+    windrectList,
+  };
+}