Răsfoiți Sursa

[Style 0000] 样式优化

houzekong 2 zile în urmă
părinte
comite
a6acd730ab

+ 334 - 0
backup/topology-before-arc/index.vue

@@ -0,0 +1,334 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<template>
+  <div class="wind-topology">
+    <!-- 工具栏 -->
+    <div class="topo-toolbar">
+      <div class="toolbar-left">
+        <div class="mine-select">
+          <MineCascader
+            v-model:value="selectedDeptId"
+            style="width: 220px"
+            :init-from-store="false"
+            :sync-from-store="false"
+            :change-on-select="false"
+            placeholder="请选择煤矿"
+            @change="setSelectedDept"
+          />
+        </div>
+        <a-divider type="vertical" />
+        <a-button-group>
+          <a-button @click="zoomIn" title="放大" :icon="h(PlusOutlined)"></a-button>
+          <a-button @click="zoomOut" title="缩小" :icon="h(MinusOutlined)"> </a-button>
+          <a-button @click="resetView" title="复位" :icon="h(ReloadOutlined)"></a-button>
+        </a-button-group>
+        <a-divider type="vertical" />
+        <!-- 双模式互斥:绑定模式(关系编辑)/ 布点模式(设备绑定) -->
+        <a-button-group>
+          <a-button :type="mode === 'relation' ? 'primary' : 'default'" @click="setMode('relation')">绑定模式</a-button>
+          <a-button :type="mode === 'device' ? 'primary' : 'default'" @click="setMode('device')">布点模式</a-button>
+        </a-button-group>
+        <a-divider type="vertical" />
+        <a-button-group>
+          <a-button :loading="saving" @click="handleSaveLayout" title="保存当前节点位置(topologyX/topologyY)">
+            <template #icon><SaveOutlined /></template>
+            保存布局
+          </a-button>
+          <a-button @click="refreshData" title="刷新">
+            <template #icon><SvgIcon name="refresh" /></template>
+            刷新
+          </a-button>
+        </a-button-group>
+        <a-divider type="vertical" />
+        <span v-if="mode === 'relation'" class="select-text">双击连线解除关系;双击节点并选中另一节点以建立关系</span>
+        <span v-else class="select-text">双击连线解绑或绑定测风装置</span>
+      </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>
+          <!-- 疑似隐蔽工作面巷道:红色加粗连线示例 -->
+          <span class="legend-item">
+            <span class="legend-bar" :style="{ background: JUDGE_COLOR, height: '5px' }"></span>
+            疑似隐蔽工作面
+          </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>
+
+    <!-- 布点模式绑定弹窗(双击未绑定布点的连线弹出;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="getWindrectListNoUsed"
+            :params="bindDeviceParams"
+            label-field="devicePos"
+            value-field="id"
+            placeholder="请选择数据点位"
+          />
+        </a-form-item>
+      </a-form>
+    </BasicModal>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
+  import { SvgIcon } from '/@/components/Icon';
+  import { ApiSelect } from '/@/components/Form';
+  import { BasicModal, useModal } from '/@/components/Modal';
+  import MineCascader from '/@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
+  import { categories, levelTextMap, levelColorMap, POINT_COLOR, JUDGE_COLOR } from './windTopology.data';
+  import { useTopology } from './hooks/useTopology';
+  import { getWindrectListNoUsed } from './windTopology.api';
+  import { h } from 'vue';
+  import { ReloadOutlined, PlusOutlined, MinusOutlined, SaveOutlined } from '@ant-design/icons-vue';
+
+  /** 图例层级项:按 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,
+    selectedDeptId,
+    setSelectedDept,
+    initChart,
+    loadTopology,
+    zoomIn,
+    zoomOut,
+    resetView,
+    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();
+    }
+  });
+
+  /** 绑定弹窗下拉参数:按当前选中矿(deptId)查询未使用测风装置 */
+  const bindDeviceParams = computed(() => ({ deptId: selectedDeptId.value }));
+
+  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>
+  .wind-topology {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    background: #f5f7fa;
+  }
+
+  .topo-toolbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 16px;
+    background: #fff;
+    border-bottom: 1px solid #e8e8e8;
+    flex-shrink: 0;
+
+    .toolbar-left {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      .mine-select {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        .select-label {
+          font-size: 13px;
+          color: #333;
+          white-space: nowrap;
+        }
+      }
+      .toolbar-title {
+        font-size: 15px;
+        font-weight: 600;
+        color: #333;
+      }
+    }
+
+    .toolbar-right .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: #666;
+        gap: 4px;
+        .legend-dot {
+          display: inline-block;
+          width: 10px;
+          height: 10px;
+          border-radius: 50%;
+        }
+        .legend-bar {
+          display: inline-block;
+          width: 18px;
+          height: 3px;
+          border-radius: 2px;
+        }
+      }
+    }
+  }
+
+  .topo-body {
+    flex: 1;
+    position: relative;
+    overflow: hidden;
+  }
+
+  .topo-canvas {
+    width: 100%;
+    height: 100%;
+  }
+
+  .detail-panel {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+    width: 240px;
+    background: #fff;
+    border-radius: 6px;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
+    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;
+      }
+    }
+    .detail-category {
+      font-size: 12px;
+      font-weight: 500;
+      margin-bottom: 2px;
+    }
+    .detail-name {
+      font-size: 16px;
+      font-weight: 600;
+      color: #333;
+      margin-bottom: 10px;
+    }
+    .detail-fields .detail-row {
+      display: flex;
+      justify-content: space-between;
+      padding: 4px 0;
+      font-size: 13px;
+      border-bottom: 1px solid #f0f0f0;
+      .detail-label {
+        color: #888;
+      }
+      .detail-value {
+        color: #333;
+        font-weight: 500;
+      }
+    }
+  }
+</style>

+ 255 - 0
backup/topology-before-arc/testdata.json

@@ -0,0 +1,255 @@
+{
+  "mineAreaList": [
+    {
+      "id": "1",
+      "mineCode": "610801006584",
+      "name": "主斜井qq",
+      "level": 1,
+      "airVolume": 5125,
+      "regulationId": null,
+      "windrectId": "1",
+      "alarmList": null,
+      "topologyX": "1050",
+      "topologyY": "-250",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-02 09:17:09",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "2",
+      "mineCode": "610801006584",
+      "name": "一采区辅运大巷",
+      "level": 2,
+      "airVolume": 456,
+      "regulationId": null,
+      "windrectId": "2",
+      "alarmList": null,
+      "topologyX": "1850",
+      "topologyY": "-700",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-03 08:37:08",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "3",
+      "mineCode": "610801006584",
+      "name": "一采区主运大巷",
+      "level": 2,
+      "airVolume": 568,
+      "regulationId": null,
+      "windrectId": "3",
+      "alarmList": null,
+      "topologyX": "2150",
+      "topologyY": "500",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-02 09:17:11",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "4",
+      "mineCode": "610801006584",
+      "name": "101运输顺槽",
+      "level": 3,
+      "airVolume": 458,
+      "regulationId": null,
+      "windrectId": "4",
+      "alarmList": null,
+      "topologyX": "2900",
+      "topologyY": "700",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-02 09:17:11",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "5",
+      "mineCode": "610801006584",
+      "name": "102运输顺槽",
+      "level": 3,
+      "airVolume": 895,
+      "regulationId": null,
+      "windrectId": "5",
+      "alarmList": null,
+      "topologyX": "2850",
+      "topologyY": "250",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-02 09:17:12",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "6",
+      "mineCode": "610801006584",
+      "name": "103运输顺槽",
+      "level": 3,
+      "airVolume": 425,
+      "regulationId": null,
+      "windrectId": "6",
+      "alarmList": null,
+      "topologyX": "2850",
+      "topologyY": "-850",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-03 09:06:32",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "7",
+      "mineCode": "610801006584",
+      "name": "104运输顺槽",
+      "level": 3,
+      "airVolume": 1246,
+      "regulationId": null,
+      "windrectId": "7",
+      "alarmList": null,
+      "topologyX": "2850",
+      "topologyY": "-150",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-02 09:17:13",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "8",
+      "mineCode": "610801006584",
+      "name": "一采区回风大巷",
+      "level": 4,
+      "airVolume": 2895,
+      "regulationId": null,
+      "windrectId": "8",
+      "alarmList": null,
+      "topologyX": "3950",
+      "topologyY": "-350",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-02 09:17:14",
+      "xcoordinate": null,
+      "ycoordinate": null
+    },
+    {
+      "id": "9",
+      "mineCode": "610801006584",
+      "name": "主回风",
+      "level": 5,
+      "airVolume": 5725,
+      "regulationId": null,
+      "windrectId": "9",
+      "alarmList": null,
+      "topologyX": "4750",
+      "topologyY": "-250",
+      "goafList": null,
+      "createTime": "2026-07-22 13:48:42",
+      "updateTime": "2026-09-03 09:06:32",
+      "xcoordinate": null,
+      "ycoordinate": null
+    }
+  ],
+  "mineAreaRelationList": [
+    {
+      "id": "2090333175566327809",
+      "mineCode": "610801006584",
+      "parentId": "1",
+      "childId": "3",
+      "createTime": "2026-08-20 15:00:40",
+      "updateTime": null
+    },
+    {
+      "id": "2090333284232355841",
+      "mineCode": "610801006584",
+      "parentId": "8",
+      "childId": "9",
+      "createTime": "2026-08-20 15:01:06",
+      "updateTime": null
+    },
+    {
+      "id": "2090333314448121857",
+      "mineCode": "610801006584",
+      "parentId": "2",
+      "childId": "7",
+      "createTime": "2026-08-20 15:01:13",
+      "updateTime": null
+    },
+    {
+      "id": "2090333332022255617",
+      "mineCode": "610801006584",
+      "parentId": "1",
+      "childId": "2",
+      "createTime": "2026-08-20 15:01:18",
+      "updateTime": null
+    },
+    {
+      "id": "2090333464654536706",
+      "mineCode": "610801006584",
+      "parentId": "2",
+      "childId": "6",
+      "createTime": "2026-08-20 15:01:49",
+      "updateTime": null
+    },
+    {
+      "id": "2090333495335870465",
+      "mineCode": "610801006584",
+      "parentId": "6",
+      "childId": "8",
+      "createTime": "2026-08-20 15:01:56",
+      "updateTime": null
+    },
+    {
+      "id": "2090333511014178818",
+      "mineCode": "610801006584",
+      "parentId": "7",
+      "childId": "8",
+      "createTime": "2026-08-20 15:02:00",
+      "updateTime": null
+    },
+    {
+      "id": "2090346736757829634",
+      "mineCode": "610801006584",
+      "parentId": "3",
+      "childId": "4",
+      "createTime": "2026-08-20 15:54:33",
+      "updateTime": null
+    },
+    {
+      "id": "2090346774074552321",
+      "mineCode": "610801006584",
+      "parentId": "4",
+      "childId": "8",
+      "createTime": "2026-08-20 15:54:42",
+      "updateTime": null
+    },
+    {
+      "id": "2090611930113314818",
+      "mineCode": "610801006584",
+      "parentId": "3",
+      "childId": "7",
+      "createTime": "2026-08-21 09:28:20",
+      "updateTime": null
+    },
+    {
+      "id": "2090627460052357122",
+      "mineCode": "610801006584",
+      "parentId": "3",
+      "childId": "5",
+      "createTime": "2026-08-21 10:30:03",
+      "updateTime": null
+    },
+    {
+      "id": "2090627481967595521",
+      "mineCode": "610801006584",
+      "parentId": "5",
+      "childId": "8",
+      "createTime": "2026-08-21 10:30:08",
+      "updateTime": null
+    }
+  ]
+}

+ 630 - 0
backup/topology-before-arc/useTopology.ts

@@ -0,0 +1,630 @@
+import { ref } from 'vue';
+import * as echarts from 'echarts';
+import type { EChartsOption } from 'echarts';
+import { message, Modal } from 'ant-design-vue';
+import { useMineDepartmentStore } from '/@/store/modules/mine';
+import {
+  createGraphOption,
+  categories,
+  levelColorMap,
+  nodeDetailFields,
+  POINT_COLOR,
+  POINT_SIZE,
+  JUDGE_COLOR,
+  JUDGE_WIDTH,
+  LAYOUT,
+} from '../windTopology.data';
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation, updateMineAreaTopology } 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/deviceCode/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 selectedDeptId = ref('');
+
+  // —— 选中节点详情 ——
+  const selectedNode = ref<TopoNodeData | null>(null);
+  const detailFields = ref<any[]>([]);
+
+  // —— 初始化 ——
+  function initChart(el: HTMLDivElement) {
+    chartInstance = echarts.init(el);
+    window.addEventListener('resize', handleResize);
+    // 容器尺寸变化(如 Tab 由隐藏变为可见 / 窗口尺寸变化)时自适应重绘,
+    // 避免 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 {
+      const mineStore = useMineDepartmentStore();
+      // 仅支持矿端(叶子节点)视图:deptId 必须是组织树中的矿井叶子;
+      // 未选中或选中非矿端(根/中间部门)时自动定位到第一个矿端
+      let mine = selectedDeptId.value ? mineStore.findDepartById(selectedDeptId.value) : undefined;
+      if (!mine || !mine.isLeaf) {
+        mine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
+      }
+      if (!mine) {
+        // 组织树未就绪(极端情况):短暂等待后重试一次
+        console.warn('组织树未就绪,300ms 后重试加载拓扑');
+        await new Promise((r) => setTimeout(r, 300));
+        mine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
+      }
+      if (!mine) {
+        console.error('组织树未就绪或不存在矿端,无法加载拓扑');
+        message.error('组织树未就绪,无法加载拓扑');
+        return;
+      }
+      selectedDeptId.value = mine.id;
+      // 接口获取 → 代码处理(关系/疑似标记/地面节点)已由 getTopologyData 完成,
+      // 此处直接消费全量数据;接口异常时 bundle 为备用空数据(仍可绘制总进地面节点)
+      const bundle = await getTopologyData({ deptId: mine.id });
+      armedSource.value = null;
+      // 测风装置显示名映射(全量列表 → 已绑定设备的 edgeLabel/解绑文案展示用)
+      deviceNameMap = {};
+      for (const d of bundle.windrectList) {
+        if (d.id) deviceNameMap[String(d.id)] = d.devicePos || d.deviceCode || String(d.id);
+      }
+      renderTopology(bundle.topology);
+      // 数据为空时给出诊断信息(总进地面节点仍会渲染)
+      if (!bundle.topology.nodes.some((n) => n.category !== 0)) {
+        console.warn('拓扑数据为空:未获取到测风地点(deptId=' + mine.id + ')');
+        message.warning('未获取到拓扑数据,请检查接口或稍后刷新');
+      }
+    } catch (e) {
+      console.error('拓扑数据加载失败:', e);
+      message.error('拓扑数据加载失败,请查看控制台日志');
+    }
+  }
+
+  /** 操作栏煤矿选择:仅允许选择矿端(叶子节点)并按所选矿查询数据。
+   *  MineCascader 已设 change-on-select=false,change 仅在选中叶子(矿端)时触发;
+   *  此处再兜底校验,非矿端(根/中间部门/空值)自动回退到第一个矿端。
+   *  注意:需先更新 selectedDeptId 再加载(不能按值去抖,否则恒等跳过) */
+  function setSelectedDept(id: string) {
+    const mineStore = useMineDepartmentStore();
+    const node = id ? mineStore.findDepartById(id) : undefined;
+    if (!node || !node.isLeaf) {
+      const firstMine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
+      if (!firstMine) {
+        message.warning('未找到矿端,请检查组织树数据');
+        return;
+      }
+      selectedDeptId.value = firstMine.id;
+    } else {
+      selectedDeptId.value = node.id;
+    }
+    loadTopology();
+  }
+
+  // —— 渲染拓扑 ——
+  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 着色 + 风流方向箭头;
+          // 疑似隐蔽工作面巷道(主要数据点位 judgeAreaList 字段有内容 → 节点 suspected)标红加粗;
+          // 中点标注(edgeLabel rich 富文本):第一行主要数据点位名称、第二行风量、第三行圆点 + 已绑定设备名;
+          // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
+          const point = l.mainPointId ? pointById.get(l.mainPointId) : undefined;
+          const lv = point ? Number(point.level) : NaN;
+          const deviceName = point?.windrectId ? deviceNameMap[point.windrectId] : '';
+          const suspected = point?.suspected;
+          // 常态标注:仅名称;高亮标注:名称 + 圆点与已绑定布点名(未绑定输出浅灰占位圆点)
+          link.midLabel = point ? `{name|${point.name}}` : '';
+          link.midLabelFull = point ? `{name|${point.name}}\n${deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'}` : '';
+          link.lineStyle = {
+            color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,
+            width: suspected ? JUDGE_WIDTH : 3,
+            opacity: suspected ? 1 : 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);
+        }
+      }
+    });
+  }
+
+  // ==================== 绑定模式:关系编辑 ====================
+
+  /** 当前选中矿的矿编码(fax),供关联接口使用 */
+  function currentMineCode(): string {
+    const mineStore = useMineDepartmentStore();
+    return mineStore.findDepartById(selectedDeptId.value)?.fax || selectedDeptId.value;
+  }
+
+  /** 清除绑定模式的关联源选中态 */
+  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 addMineAreaRelation({ mineCode: currentMineCode(), parentId: parent.rawId, childId: child.rawId });
+          clearArmed();
+          await loadTopology();
+          message.success('关联成功');
+        } catch {
+          message.error('关联失败');
+        }
+      },
+      onCancel: () => clearArmed(),
+    });
+  }
+
+  /** 绑定模式:双击连线 → 解除巷道关系(deleteMineAreaRelation 传入关系 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 deleteMineAreaRelation({ 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.windrectId) {
+      confirmUnbindDevice(point);
+    } else {
+      bindPoint.value = point;
+      bindDeviceId.value = undefined;
+      bindVisible.value = true;
+    }
+  }
+
+  /** 关闭布点绑定弹窗(index.vue 取消/关闭时同步,成功关闭由 confirmBind 内部调用) */
+  function closeBindDialog() {
+    bindVisible.value = false;
+  }
+
+  /** 布点模式:弹窗选择测风装置后调用(updateMineArea 更新 windrectId 字段) */
+  async function confirmBind(deviceId?: string) {
+    const point = bindPoint.value;
+    if (!point?.rawId) {
+      message.warning('缺少测风地点原始 id,无法绑定');
+      return;
+    }
+    if (!deviceId) {
+      message.warning('请选择数据点位');
+      return;
+    }
+    try {
+      await updateArea({ id: point.rawId, windrectId: deviceId });
+      closeBindDialog();
+      await loadTopology();
+      message.success('绑定成功');
+    } catch {
+      message.error('绑定失败');
+    }
+  }
+
+  /** 布点模式:解绑测风装置(清空 windrectId 字段,updateMineArea) */
+  function confirmUnbindDevice(point: TopoNodeData) {
+    const deviceName = deviceNameOf(point.windrectId);
+    Modal.confirm({
+      title: '确认解绑',
+      content: `确定解除"${point.name}"与测风装置"${deviceName}"的绑定关系?`,
+      okText: '确认解绑',
+      cancelText: '取消',
+      onOk: async () => {
+        if (!point.rawId) {
+          message.warning('缺少测风地点原始 id,无法解绑');
+          return;
+        }
+        try {
+          await updateArea({ id: point.rawId, windrectId: '' });
+          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)当前位置写入
+   * updateMineAreaTopology 的 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 updateMineAreaTopology(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,
+    selectedDeptId,
+    setSelectedDept,
+    initChart,
+    loadTopology,
+    zoomIn,
+    zoomOut,
+    resetView,
+    clearSelection,
+    saveTopologyLayout,
+    dispose,
+  };
+}

+ 312 - 0
backup/topology-before-arc/useTopologyLayout.spec.ts

@@ -0,0 +1,312 @@
+import {
+  buildRelationArray,
+  transformToTopologyData,
+  LAYOUT,
+  ROOT_IN_ID,
+  ROOT_OUT_ID,
+} from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout';
+
+/** 构造测风地点(MineArea) */
+const area = (id: string, level: number, airVolume = 100, extra: Record<string, any> = {}) => ({
+  id,
+  name: id,
+  level,
+  airVolume,
+  mineCode: 'M1',
+  ...extra,
+});
+
+/** 构造巷道关系(MineAreaRelation) */
+const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
+  id,
+  mineCode: 'M1',
+  parentId,
+  childId,
+  ...extra,
+});
+
+/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系) */
+function laneTopo(): TopologyData {
+  const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5)];
+  const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]);
+  return transformToTopologyData(areas, relations);
+}
+
+describe('buildRelationArray 关系数组生成', () => {
+  test('过滤 parent/child 引用缺失的脏数据,保留关系主键 id', () => {
+    const areas = [area('1', 1), area('2', 2)];
+    const relations = [rel('r1', '1', '2'), rel('r2', '9', '2'), rel('r3', '1', '8'), rel('r4', '', '2')];
+    const arr = buildRelationArray(areas, relations);
+    expect(arr).toHaveLength(1);
+    expect(arr[0].id).toBe('r1');
+    expect(arr[0].parent.id).toBe('1');
+    expect(arr[0].child.id).toBe('2');
+  });
+});
+
+describe('transformToTopologyData 巷道线模型', () => {
+  test('生成总进/总回地面节点;全部点位入节点;关系连线带 relationId/pointId;根边无 relationId;无布点节点;流向正确', () => {
+    const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5), area('u1', 3, 10)];
+    const relations = [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, relations));
+
+    // 总进/总回地面节点
+    expect(data.nodes.some((n) => n.id === ROOT_IN_ID && n.name === '地面')).toBe(true);
+    expect(data.nodes.some((n) => n.id === ROOT_OUT_ID && n.name === '地面')).toBe(true);
+    expect(data.nodes.filter((n) => n.name === '地面')).toHaveLength(2);
+    // 全部点位入节点(含无关系点位 u1)
+    for (const a of areas) {
+      expect(data.nodes.some((n) => n.id === `point:${a.id}`)).toBe(true);
+    }
+    // 关系连线带 relationId + pointId(子节点)
+    const l = data.links.find((x) => x.relationId === 'r1');
+    expect(l?.source).toBe('point:a1');
+    expect(l?.target).toBe('point:b1');
+    expect(l?.pointId).toBe('point:b1');
+    // 主要数据点位:进风/用风取子节点、回风取父节点
+    expect(data.links.find((x) => x.relationId === 'r1')?.mainPointId).toBe('point:b1'); // a1→b1 进风,子节点
+    expect(data.links.find((x) => x.relationId === 'r2')?.mainPointId).toBe('point:c1'); // b1→c1 用风,子节点
+    expect(data.links.find((x) => x.relationId === 'r3')?.mainPointId).toBe('point:c1'); // c1→d1 回风,父节点
+    expect(data.links.find((x) => x.relationId === 'r4')?.mainPointId).toBe('point:d1'); // d1→e1 回风,父节点
+    // 根边 地面→lv1、lv5→地面 均无 relationId,主要数据分别为 lv1 子节点 / lv5 父节点
+    const rootIn = data.links.find((x) => x.source === ROOT_IN_ID && x.target === 'point:a1');
+    expect(rootIn?.relationId).toBeUndefined();
+    expect(rootIn?.mainPointId).toBe('point:a1');
+    const rootOut = data.links.find((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID);
+    expect(rootOut?.relationId).toBeUndefined();
+    expect(rootOut?.mainPointId).toBe('point:e1');
+    // 无测风装置节点
+    expect(data.nodes.some((n) => n.category === 2)).toBe(false);
+    // 流向:子节点 level ≤3 进风、>3 回风
+    expect(data.links.find((x) => x.relationId === 'r1')?.flow).toBe('intake');
+    expect(data.links.find((x) => x.relationId === 'r3')?.flow).toBe('return');
+  });
+
+  test('judgeAreaList 字段有内容 → suspected=true;无内容 → false(兼容 alarmList)', () => {
+    const areas = [
+      area('a1', 1, 100, { judgeAreaList: [{ id: 'x' }] }),
+      area('b1', 2, 90, { judgeAreaList: null }),
+      area('c1', 3, 80, { judgeAreaList: [] }),
+      area('d1', 4, 70, { alarmList: ['y'] }), // 历史字段兼容
+    ];
+    const data = transformToTopologyData(areas, []);
+    expect(data.nodes.find((n) => n.id === 'point:a1')?.suspected).toBe(true);
+    expect(data.nodes.find((n) => n.id === 'point:b1')?.suspected).toBe(false);
+    expect(data.nodes.find((n) => n.id === 'point:c1')?.suspected).toBe(false);
+    expect(data.nodes.find((n) => n.id === 'point:d1')?.suspected).toBe(true);
+  });
+
+  test('topologyX/topologyY 透传到节点(数字与字符串输入)', () => {
+    const areas = [area('a1', 1, 100, { topologyX: 123.5, topologyY: 456 }), area('b1', 2, 90, { topologyX: '200', topologyY: '300' })];
+    const data = transformToTopologyData(areas, []);
+    expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyX).toBe(123.5);
+    expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyY).toBe(456);
+    expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyX).toBe('200');
+    expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyY).toBe('300');
+    // 地面节点无该字段
+    expect(data.nodes.find((n) => n.id === ROOT_IN_ID)?.topologyX).toBeUndefined();
+  });
+
+  test('动态根边:有 lv0/lv6 时 总进→lv0、lv6→总回;缺失时回退 lv1/lv5', () => {
+    // 场景一:存在 lv0(进风井) 与 lv6(回风井) → 根边连 lv0/lv6,不再连 lv1/lv5
+    const areas1 = [area('z0', 0), area('a1', 1), area('e1', 5), area('z6', 6)];
+    const data1 = transformToTopologyData(areas1, []);
+    expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:z0')).toBe(true);
+    expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(false);
+    expect(data1.links.some((x) => x.source === 'point:z6' && x.target === ROOT_OUT_ID)).toBe(true);
+    expect(data1.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(false);
+    // 场景二:无 lv0/lv6 → 回退 总进→lv1、lv5→总回
+    const areas2 = [area('a1', 1), area('e1', 5)];
+    const data2 = transformToTopologyData(areas2, []);
+    expect(data2.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(true);
+    expect(data2.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(true);
+  });
+});
+
+describe('computeLayout 列式布局(固定间距)', () => {
+  const centerY = 350; // 700 / 2
+  const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
+
+  test('列序:总进 → lv0..lv6 → 总回,x 依次递增固定 colGap;单节点列垂直居中', () => {
+    const layout = computeLayout(laneTopo(), 1200, 700);
+    const pos = layout.positions;
+    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
+    // 列序:总进(0) → lv0(1) → lv1(2) → lv2(3) → lv3(4) → lv4(5) → lv5(6) → lv6(7) → 未分级(8) → 总回(9)
+    expect(pos['point:a1'].x).toBe(laneX(2));
+    expect(pos['point:b1'].x).toBe(laneX(3));
+    expect(pos['point:c1'].x).toBe(laneX(4));
+    expect(pos['point:d1'].x).toBe(laneX(5));
+    expect(pos['point:e1'].x).toBe(laneX(6));
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回
+    expect(pos[ROOT_IN_ID].y).toBe(centerY);
+    expect(pos[ROOT_OUT_ID].y).toBe(centerY);
+  });
+
+  test('新增层级 lv0(进风井)/lv6(回风井) 列序:位于 lv1 之前、lv5 之后', () => {
+    const areas = [area('a1', 1), area('z0', 0), area('e1', 5), area('z6', 6)];
+    const data = transformToTopologyData(areas, []);
+    const pos = computeLayout(data, 1200, 700).positions;
+    expect(pos['point:z0'].x).toBe(laneX(1)); // lv0 在 lv1 前
+    expect(pos['point:a1'].x).toBe(laneX(2));
+    expect(pos['point:e1'].x).toBe(laneX(6));
+    expect(pos['point:z6'].x).toBe(laneX(7)); // lv6 在 lv5 后
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(9));
+  });
+
+  test('同层级同列,行距 = 该层级 rowGap(固定),围绕中心对称', () => {
+    const data = laneTopo();
+    data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
+    const pos = computeLayout(data, 1200, 700).positions;
+    expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
+    const expected = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault;
+    expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(expected);
+    expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
+  });
+
+  test('colGap 固定生效', () => {
+    const orig = LAYOUT.colGap;
+    try {
+      LAYOUT.colGap = 250;
+      const pos = computeLayout(laneTopo(), 1200, 700).positions;
+      // 相邻层级列间距 = colGap(a1 为 lv1,与总进之间隔 lv0 空列)
+      expect(pos['point:b1'].x - pos['point:a1'].x).toBe(250);
+      expect(pos['point:c1'].x - pos['point:b1'].x).toBe(250);
+    } finally {
+      LAYOUT.colGap = orig;
+    }
+  });
+
+  test('rowGap 按 level 分别配置生效:只影响对应层级列', () => {
+    const orig2 = LAYOUT.rowGap[2];
+    try {
+      LAYOUT.rowGap[2] = 300;
+      const data = laneTopo();
+      data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
+      data.nodes.push({ id: 'point:c2', name: 'c2', category: 1, level: 3, airVolume: 75 });
+      const pos = computeLayout(data, 1200, 700).positions;
+      // lv2 列使用本次配置值 300
+      expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(300);
+      // lv3 列仍使用其自身配置值(未被本次修改影响)
+      const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
+      expect(Math.abs(pos['point:c2'].y - pos['point:c1'].y)).toBe(gap3);
+      // 两列分别围绕 centerY 对称
+      expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
+      expect((pos['point:c1'].y + pos['point:c2'].y) / 2).toBeCloseTo(centerY, 5);
+    } finally {
+      LAYOUT.rowGap[2] = orig2;
+    }
+  });
+
+  test('多条进风-用风关系数据:全部点位有合法坐标、无 NaN、不越界', () => {
+    const areas = [area('a1', 1, 500), area('a2', 1, 400), area('c1', 3, 300), area('c2', 3, 200), area('e1', 5, 100), area('e2', 5, 90)];
+    const relations = buildRelationArray(areas, [rel('r1', 'a1', 'c1'), rel('r2', 'a2', 'c2'), rel('r3', 'c1', 'e1'), rel('r4', 'c2', 'e2')]);
+    const data = transformToTopologyData(areas, relations);
+    const layout = computeLayout(data, 1200, 700);
+    for (const a of areas) {
+      const pos = layout.positions[`point:${a.id}`];
+      expect(pos).toBeDefined();
+      expect(Number.isFinite(pos.x)).toBe(true);
+      expect(Number.isFinite(pos.y)).toBe(true);
+      expect(pos.y).toBeGreaterThanOrEqual(0);
+      expect(pos.y).toBeLessThan(700);
+    }
+    // 两个进风点同列(lv1)、两个用风点同列(lv3)、两个回风点同列(lv5)
+    expect(layout.positions['point:a1'].x).toBe(layout.positions['point:a2'].x);
+    expect(layout.positions['point:c1'].x).toBe(layout.positions['point:c2'].x);
+    expect(layout.positions['point:e1'].x).toBe(layout.positions['point:e2'].x);
+    // 各 2 节点列按各自层级行距围绕 centerY 对称(首节点 = centerY − 行距/2)
+    const gap1 = LAYOUT.rowGap[1] ?? LAYOUT.rowGapDefault;
+    const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
+    const gap5 = LAYOUT.rowGap[5] ?? LAYOUT.rowGapDefault;
+    expect(layout.positions['point:a1'].y).toBeCloseTo(centerY - gap1 / 2, 5);
+    expect(layout.positions['point:c1'].y).toBeCloseTo(centerY - gap3 / 2, 5);
+    expect(layout.positions['point:e1'].y).toBeCloseTo(centerY - gap5 / 2, 5);
+    expect((layout.positions['point:a1'].y + layout.positions['point:a2'].y) / 2).toBeCloseTo(centerY, 5);
+  });
+
+  test('不同节点数列独立垂直居中:1/2/4 节点列各自围绕 centerY 对称,单节点列在 centerY', () => {
+    // 列分布:总进(1) lv1(1) lv2(2) lv3(4) lv4(2) lv5(1) 总回(1)
+    const areas = [
+      area('a1', 1, 500),
+      area('b1', 2, 300),
+      area('b2', 2, 200),
+      area('c1', 3, 400),
+      area('c2', 3, 300),
+      area('c3', 3, 200),
+      area('c4', 3, 100),
+      area('d1', 4, 250),
+      area('d2', 4, 150),
+      area('e1', 5, 100),
+    ];
+    const data = transformToTopologyData(areas, []);
+    const layout = computeLayout(data, 1200, 700);
+    const pos = layout.positions;
+    // 单节点列(总进、lv1、lv5、总回)垂直居中
+    expect(pos[ROOT_IN_ID].y).toBe(centerY);
+    expect(pos['point:a1'].y).toBe(centerY);
+    expect(pos['point:e1'].y).toBe(centerY);
+    expect(pos[ROOT_OUT_ID].y).toBe(centerY);
+    // 2 节点列(lv2/lv4)围绕 centerY 对称,中点 = centerY,行距取各自层级配置
+    const gap2 = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault;
+    const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
+    const gap4 = LAYOUT.rowGap[4] ?? LAYOUT.rowGapDefault;
+    expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
+    expect((pos['point:d1'].y + pos['point:d2'].y) / 2).toBeCloseTo(centerY, 5);
+    expect(Math.abs(pos['point:b1'].y - pos['point:b2'].y)).toBe(gap2);
+    expect(Math.abs(pos['point:d1'].y - pos['point:d2'].y)).toBe(gap4);
+    // 4 节点列(lv3)围绕 centerY 对称,中点 = centerY,跨度 = 3×该层级行距
+    expect((pos['point:c1'].y + pos['point:c4'].y) / 2).toBeCloseTo(centerY, 5);
+    expect(Math.abs(pos['point:c1'].y - pos['point:c4'].y)).toBe(3 * gap3);
+    // 中间列跨度大于两端列(菱形轮廓)
+    expect(3 * gap3).toBeGreaterThan(gap2);
+    expect(gap2).toBeGreaterThan(0);
+  });
+
+  test('空数据(仅总进/总回地面节点)可布局且不抛错', () => {
+    const layout = computeLayout(
+      {
+        nodes: [
+          { id: ROOT_IN_ID, name: '地面', category: 0 },
+          { id: ROOT_OUT_ID, name: '地面', category: 0 },
+        ],
+        links: [],
+      },
+      1200,
+      700
+    );
+    expect(layout.positions[ROOT_IN_ID]).toBeDefined();
+    expect(layout.positions[ROOT_OUT_ID]).toBeDefined();
+    expect(layout.positions[ROOT_IN_ID].y).toBe(centerY);
+    expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY);
+  });
+
+  test('布局保存坐标覆盖:有效坐标直接使用;null/空串/0/NaN 等无效坐标回退列布局(不挤到原点)', () => {
+    const areas = [
+      area('a1', 1, 100, { topologyX: 888, topologyY: 666 }),
+      area('b1', 2, 90, { topologyX: '999.5', topologyY: '333' }), // 字符串数字同样生效
+      area('c1', 3, 80, { topologyX: 'abc', topologyY: NaN }), // 非数字 → 回退
+      area('d1', 4, 70), // 无坐标 → 回退
+      area('e1', 5, 60, { topologyX: 0, topologyY: null }), // 后端默认 0/null → 回退
+      area('f1', 0, 50, { topologyX: '', topologyY: '0' }), // 空串/'0' → 回退(lv0 进风井列)
+    ];
+    const data = transformToTopologyData(areas, []);
+    const pos = computeLayout(data, 1200, 700).positions;
+    // 有效保存坐标直接使用
+    expect(pos['point:a1']).toEqual({ x: 888, y: 666 });
+    expect(pos['point:b1']).toEqual({ x: 999.5, y: 333 });
+    // c1 非法坐标回退列布局(lv3 列,垂直居中),而非覆盖到 (0,0)
+    expect(pos['point:c1'].x).toBe(laneX(4)); // 总进(0) lv0(1) lv1(2) lv2(3) lv3(4)
+    expect(pos['point:c1'].y).toBe(centerY);
+    // 无坐标节点正常列布局
+    expect(pos['point:d1'].x).toBe(laneX(5)); // lv4
+    // 后端默认 0/null/''/0 字符串:回退列布局,绝不挤到原点
+    expect(pos['point:e1'].x).toBe(laneX(6)); // lv5
+    expect(pos['point:e1'].y).toBe(centerY);
+    expect(pos['point:f1'].x).toBe(laneX(1)); // lv0 进风井列
+    expect(pos['point:f1'].y).toBe(centerY);
+    expect(pos['point:e1'].x).not.toBe(0);
+    expect(pos['point:f1'].y).not.toBe(0);
+  });
+});

+ 113 - 0
backup/topology-before-arc/useTopologyLayout.ts

@@ -0,0 +1,113 @@
+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);
+ *   每列围绕画布垂直中心独立居中:colStart = centerY − spacing·(n−1)/2,
+ *   节点 y = colStart + 秩 × spacing;列内顺序由 useTopologyOrdering 交叉最小化排序
+ *   (median/barycenter 多轮扫描)决定;
+ *   节点自带有效 topologyX/topologyY(布局保存坐标,像素,非 0/null/'')时直接使用,跳过列布局。
+ */
+export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
+  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 cols: TopoNodeData[][] = [];
+  cols.push(rootIn ? [rootIn] : []);
+  for (let lv = 0; lv <= 6; lv++) {
+    const col = byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : [];
+    applyOrder(col, String(lv));
+    cols.push(col);
+  }
+  const ug = ungraded.length ? [...ungraded] : [];
+  applyOrder(ug, 'ungraded');
+  cols.push(ug);
+  cols.push(rootOut ? [rootOut] : []);
+
+  // 逐列赋坐标:x = margin + colGap × 列序;行距取该层级配置,每列独立居中
+  cols.forEach((col, i) => {
+    const x = laneX(i);
+    const n = col.length;
+    if (n === 0) return;
+    const spacing = rowGapFor(col[0].level);
+    const start = centerY - (spacing * (n - 1)) / 2;
+    col.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 };
+}

+ 273 - 0
backup/topology-before-arc/useTopologyOrdering.spec.ts

@@ -0,0 +1,273 @@
+import {
+  buildRelationArray,
+  transformToTopologyData,
+  LAYOUT,
+  ROOT_IN_ID,
+  ROOT_OUT_ID,
+} from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import {
+  orderNodesByLevel,
+  countCrossings,
+  countTotalCrossings,
+} from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyOrdering';
+import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout';
+
+/** 构造测风地点(MineArea) */
+const area = (id: string, level: number, airVolume = 100, extra: Record<string, any> = {}) => ({
+  id,
+  name: id,
+  level,
+  airVolume,
+  mineCode: 'M1',
+  ...extra,
+});
+
+/** 构造巷道关系(MineAreaRelation) */
+const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
+  id,
+  mineCode: 'M1',
+  parentId,
+  childId,
+  ...extra,
+});
+
+/** 相邻层边方向规范化:排序不关心渲染方向(parent→child),只关心端点所属层 */
+function pairEdgesOf(data: TopologyData): Map<string, Array<[string, string]>> {
+  // 复算 orderNodesByLevel 内部的相邻层边表(与实现保持一致:level 差恰为 1 的巷道边)
+  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 map = 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 || Math.abs(sl - tl) !== 1) continue;
+    const key = `${Math.min(sl, tl)}-${Math.max(sl, tl)}`;
+    if (!map.has(key)) map.set(key, []);
+    map.get(key)!.push(sl < tl ? [l.source, l.target] : [l.target, l.source]);
+  }
+  return map;
+}
+
+describe('countCrossings 交叉数统计', () => {
+  test('秩相对顺序相反的一对边计 1 交叉,相同不计', () => {
+    // a 在 x 上方、y 在 b 上方:边 a-y 与 b-x 交叉
+    const rankA = new Map([
+      ['a', 0],
+      ['b', 1],
+    ]);
+    const rankB = new Map([
+      ['x', 0],
+      ['y', 1],
+    ]);
+    expect(
+      countCrossings(rankA, rankB, [
+        ['a', 'y'],
+        ['b', 'x'],
+      ])
+    ).toBe(1);
+    expect(
+      countCrossings(rankA, rankB, [
+        ['a', 'x'],
+        ['b', 'y'],
+      ])
+    ).toBe(0);
+    // 无公共端点秩(防御:缺失端点跳过不计数)
+    expect(
+      countCrossings(rankA, rankB, [
+        ['a', 'y'],
+        ['unknown', 'x'],
+      ])
+    ).toBe(0);
+  });
+});
+
+describe('orderNodesByLevel median 扫描法', () => {
+  test('2×2 交叉场景:排序后相邻层交叉数为 0', () => {
+    // lv1=[a1,a2]、lv2=[b1,b2](输入序);边 a1→b2、a2→b1 → 初始 1 交叉
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
+    const order = orderNodesByLevel(data);
+    // 参与层存在且含全部节点
+    expect(order.get('1')).toHaveLength(2);
+    expect(order.get('2')).toHaveLength(2);
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+
+  test('确定性:同一输入两次调用结果完全一致(含 barycenter 方法)', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('a3', 1), area('b1', 2), area('b2', 2), area('b3', 2)];
+    const relations = buildRelationArray(areas, [
+      rel('r1', 'a1', 'b2'),
+      rel('r2', 'a2', 'b3'),
+      rel('r3', 'a3', 'b1'),
+      rel('r1b', 'a1', 'b3'),
+      rel('r2b', 'a2', 'b1'),
+    ]);
+    const data = transformToTopologyData(areas, relations);
+    for (const method of ['median', 'barycenter'] as const) {
+      const o1 = orderNodesByLevel(data, { method });
+      const o2 = orderNodesByLevel(data, { method });
+      expect(o1.get('1')).toEqual(o2.get('1'));
+      expect(o1.get('2')).toEqual(o2.get('2'));
+    }
+  });
+
+  test('孤立节点(无相邻层边)排在层尾且保持相对输入序', () => {
+    // x1/x2 邻居均为 a1(位置相同 → 保持输入序);y 无任何相邻层边 → +Infinity 排层尾
+    const areas = [area('a1', 1), area('x1', 2), area('x2', 2), area('y', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'x1'), rel('r2', 'a1', 'x2')]));
+    const order = orderNodesByLevel(data);
+    expect(order.get('2')).toEqual(['point:x1', 'point:x2', 'point:y']);
+  });
+
+  test('未分级列保持输入序,不参与扫描', () => {
+    // level 0/6 已是有效层级(进风井/回风井),未分级用越界值 7 表示
+    const areas = [area('g1', 7), area('g2', 7), area('g3', 7)];
+    const data = transformToTopologyData(areas, []);
+    const order = orderNodesByLevel(data);
+    expect(order.get('ungraded')).toEqual(['point:g1', 'point:g2', 'point:g3']);
+  });
+
+  test('跨层边(level 差 >1)不参与排序:中间层保持输入序,不抛错', () => {
+    // 仅 a1(lv1)→c1(lv3) 跨层边;lv2 的 b1/b2 无相邻层边 → 均排层尾且保持相对顺序
+    const areas = [area('a1', 1), area('b1', 2), area('b2', 2), area('c1', 3)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'c1')]));
+    const order = orderNodesByLevel(data);
+    expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+
+  test('空数据(仅地面节点):各层空数组 + ungraded 空数组,不抛错', () => {
+    const data: TopologyData = {
+      nodes: [
+        { id: ROOT_IN_ID, name: '地面', category: 0 },
+        { id: ROOT_OUT_ID, name: '地面', category: 0 },
+      ],
+      links: [],
+    };
+    const order = orderNodesByLevel(data);
+    for (const key of ['0', '1', '2', '3', '4', '5', '6', 'ungraded']) {
+      expect(order.get(key)).toEqual([]);
+    }
+  });
+
+  test('sweepRounds 配置生效且不抛错', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
+    const order = orderNodesByLevel(data, { sweepRounds: 1 });
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+
+  test('初始序为输入序(无相邻层边时列序完全不变)', () => {
+    // 无任何巷道边:各层节点保持 data.nodes 输入序
+    const areas = [area('a1', 1, 10), area('a2', 1, 90), area('b1', 2, 50), area('b2', 2, 30)];
+    const data = transformToTopologyData(areas, []);
+    const order = orderNodesByLevel(data);
+    expect(order.get('1')).toEqual(['point:a1', 'point:a2']);
+    expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
+  });
+
+  test('新增层级 lv0(进风井)/lv6(回风井) 参与排序:相邻层对 0-1、5-6 交叉消除', () => {
+    // lv0=[a0,a1]、lv1=[b1,b2]:边 a0→b2、a1→b1 → 初始 1 交叉
+    // lv5=[e1,e2]、lv6=[f1,f2]:边 e1→f2、e2→f1 → 初始 1 交叉
+    const areas = [area('a0', 0), area('a1', 0), area('b1', 1), area('b2', 1), area('e1', 5), area('e2', 5), area('f1', 6), area('f2', 6)];
+    const relations = buildRelationArray(areas, [rel('r1', 'a0', 'b2'), rel('r2', 'a1', 'b1'), rel('r3', 'e1', 'f2'), rel('r4', 'e2', 'f1')]);
+    const data = transformToTopologyData(areas, relations);
+    const order = orderNodesByLevel(data);
+    expect(order.get('0')).toHaveLength(2);
+    expect(order.get('1')).toHaveLength(2);
+    expect(order.get('5')).toHaveLength(2);
+    expect(order.get('6')).toHaveLength(2);
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+});
+
+describe('computeLayout 与排序集成', () => {
+  const centerY = 350; // 700 / 2
+  const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
+
+  test('2×2 交叉场景:排序后坐标合法、x 列序不变、lv2 按交叉最小化重排', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
+    const pos = computeLayout(data, 1200, 700).positions;
+
+    for (const a of areas) {
+      const p = pos[`point:${a.id}`];
+      expect(p).toBeDefined();
+      expect(Number.isFinite(p.x)).toBe(true);
+      expect(Number.isFinite(p.y)).toBe(true);
+      expect(p.y).toBeGreaterThanOrEqual(0);
+      expect(p.y).toBeLessThan(700);
+    }
+    // x 列序不变:lv1 同列、lv2 同列
+    expect(pos['point:a1'].x).toBe(pos['point:a2'].x);
+    expect(pos['point:b1'].x).toBe(pos['point:b2'].x);
+    expect(pos['point:a1'].x).toBe(laneX(2)); // 总进(0) lv0(1) lv1(2)
+    expect(pos['point:b1'].x).toBe(laneX(3));
+    // 排序生效:lv2 重排为 [b2, b1](b2 在 b1 上方),消除交叉
+    expect(pos['point:b2'].y).toBeLessThan(pos['point:b1'].y);
+    // 每列仍围绕中心对称
+    expect((pos['point:a1'].y + pos['point:a2'].y) / 2).toBeCloseTo(centerY, 5);
+    expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
+  });
+
+  test('5 层线状模型(laneTopo 等价):全部坐标合法,交叉数为 0', () => {
+    const areas = [area('a1', 1), area('b1', 2), area('c1', 3), area('d1', 4), area('e1', 5)];
+    const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]);
+    const data = transformToTopologyData(areas, relations);
+    const order = orderNodesByLevel(data);
+    expect(countTotalCrossings(data, order)).toBe(0);
+    const pos = computeLayout(data, 1200, 700).positions;
+    for (const a of areas) {
+      const p = pos[`point:${a.id}`];
+      expect(Number.isFinite(p.x)).toBe(true);
+      expect(Number.isFinite(p.y)).toBe(true);
+      expect(p.y).toBeGreaterThanOrEqual(0);
+      expect(p.y).toBeLessThan(700);
+    }
+  });
+
+  test('排序不改变列序与总进/总回地面节点位置', () => {
+    const areas = [area('a1', 1), area('b1', 2), area('c1', 3), area('d1', 4), area('e1', 5)];
+    const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]);
+    const data = transformToTopologyData(areas, relations);
+    const pos = computeLayout(data, 1200, 700).positions;
+    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
+    expect(pos['point:a1'].x).toBe(laneX(2)); // 总进(0) lv0(1) lv1(2)
+    expect(pos['point:e1'].x).toBe(laneX(6)); // lv5 在 lv0..lv6 列序中为第 6 列
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回
+    expect(pos[ROOT_IN_ID].y).toBe(centerY);
+    expect(pos[ROOT_OUT_ID].y).toBe(centerY);
+  });
+
+  test('pairEdgesOf 复算与 countTotalCrossings 一致(防实现漂移)', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2), area('c1', 3)];
+    const relations = buildRelationArray(areas, [
+      rel('r1', 'a1', 'b2'),
+      rel('r2', 'a2', 'b1'),
+      rel('r3', 'b2', 'c1'),
+      rel('r4', 'a1', 'c1'), // 跨层边:不参与
+    ]);
+    const data = transformToTopologyData(areas, relations);
+    const order = orderNodesByLevel(data);
+    // 独立复算相邻层边,再统计交叉数,应与 countTotalCrossings 一致
+    const edges = pairEdgesOf(data);
+    const rankOf = (key: string) => {
+      const r = new Map<string, number>();
+      (order.get(key) || []).forEach((id, i) => r.set(id, i));
+      return r;
+    };
+    let manual = 0;
+    for (let i = 0; i <= 5; i++) {
+      const es = edges.get(`${i}-${i + 1}`);
+      if (!es) continue;
+      manual += countCrossings(rankOf(String(i)), rankOf(String(i + 1)), es);
+    }
+    expect(manual).toBe(countTotalCrossings(data, order));
+  });
+});

+ 237 - 0
backup/topology-before-arc/useTopologyOrdering.ts

@@ -0,0 +1,237 @@
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+
+/**
+ * 通风网络拓扑层内排序:median / barycenter 多轮扫描法(Sugiyama 框架的 crossing reduction 阶段)。
+ *
+ * 背景:层级已由 level 字段固定为语义列(1=矿井进风 … 5=矿井回风),列内排序只影响相邻层
+ * 巷道连线的交叉数。二部交叉最小化(bipartite crossing minimization)为 NP-hard,
+ * 本模块采用工业图布局库(dagre/ELK)同款启发式:
+ *   1) 每层节点按"其相邻层邻居位置的 median(中位数)/ barycenter(均值)"计算关键值;
+ *   2) 左右多轮往返扫描(down/up sweep),保留历史交叉数最优解。
+ *
+ * 确定性保证:固定轮次(默认 20)+ 三元比较稳定排序(关键值、输入 index),
+ * 同一输入永远输出同一顺序,避免 resize / 刷新导致的布局抖动。
+ *
+ * 不参与排序、保持输入序(不影响交叉数的节点/边):
+ *   - 未分级列(level 无效);
+ *   - 跨层巷道边(两端 level 差 > 1)及其端点;
+ *   - 根边(地面 ↔ lv1 / lv5)与地面节点(单节点列)。
+ * 无任何相邻层边的节点关键值为 +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 '1'..'5') */
+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:'1'..'5' 与 'ungraded')。
+ * - 参与排序层(lv1..lv5):以输入序(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;
+}

+ 64 - 0
backup/topology-before-arc/windTopology.api.ts

@@ -0,0 +1,64 @@
+import { defHttp } from '/@/utils/http/axios';
+import { useMineDepartmentStore } from '/@/store/modules/mine';
+import type { MineAreaNode, MineAreaRelationNode, TopologyBundle, WindrectNode } from './windTopology.data';
+import { buildTopologyBundle } from './windTopology.data';
+import { RequestOptions } from '/#/axios';
+
+enum Api {
+  getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
+  addMineAreaRelation = '/workingface/mineArea/addMineAreaRelation',
+  deleteMineAreaRelation = '/workingface/mineArea/deleteMineAreaRelation',
+  getWindrectList = '/workingface/windrect/getWindrectList',
+  getWindrectListNoUsed = '/workingface/windrect/getWindrectListNoUsed',
+  updateMineArea = '/workingface/mineArea/updateMineArea',
+  updateMineAreaTopology = '/workingface/mineArea/updateMineAreaTopology',
+}
+
+/**
+ * 获取测风网络拓扑全量数据(接口获取 → 代码处理 → 返回完整数据供消费):
+ *   getMineAreaRelation -> 测风地点列表 + 巷道关系列表(按矿编码 mineCode=fax 查询)
+ *   getWindrectList     -> 测风装置全量列表(按 deptId 查询,用于解析已绑定设备显示名)
+ * 所有接口并行请求(allSettled),单个失败不影响整体;整体异常时返回备用空数据
+ * (总进地面节点 + 空列表),保证拓扑图总能绘制。
+ */
+export const getTopologyData = async (params?: any): Promise<TopologyBundle> => {
+  try {
+    const mineStore = useMineDepartmentStore();
+    // deptId 为当前选中的矿端部门 id;getMineAreaRelation 按矿编码(fax)查询
+    const deptId = params?.deptId || mineStore.getRootId;
+    const mineCode = mineStore.findDepartById(deptId)?.fax || deptId;
+    // 兼容两种响应结构:分页 { 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.getMineAreaRelation, params: { mineCode } }, { joinParamsToUrl: true }),
+      defHttp.post({ url: Api.getWindrectList, params: { deptId, column: 'createTime', order: 'desc' } }, { joinParamsToUrl: true }),
+    ]);
+    // 单个接口失败不影响另一个(避免整体空白);失败时记录原因便于定位
+    const relData = relRes.status === 'fulfilled' ? (relRes.value as any) || {} : (console.error('获取巷道关系失败:', relRes.reason), {});
+    const areaList = Array.isArray(relData.mineAreaList) ? (relData.mineAreaList as MineAreaNode[]) : [];
+    const relationList = Array.isArray(relData.mineAreaRelationList) ? (relData.mineAreaRelationList as MineAreaRelationNode[]) : [];
+    const windrectList =
+      windrectRes.status === 'fulfilled' ? toList(windrectRes.value) : (console.error('获取测风装置失败:', windrectRes.reason), []);
+
+    return buildTopologyBundle(areaList, relationList, windrectList as WindrectNode[]);
+  } catch (e) {
+    // 备用空数据兜底:保证拓扑图(总进地面节点)在接口异常时仍可绘制
+    console.error('拓扑数据组装失败,使用空数据兜底:', e);
+    return buildTopologyBundle([], [], []);
+  }
+};
+
+/** 编辑测风地点(绑定/解绑测风装置通过字段:id=测风地点主键、windrectId=绑定测风装置 id) */
+export const updateArea = (params?: any, opts?: RequestOptions) => defHttp.post({ url: Api.updateMineArea, params }, opts);
+
+export const updateMineAreaTopology = (params?: any) => defHttp.post({ url: Api.updateMineAreaTopology, params });
+
+/** 查询未使用测风装置列表(按 deptId 查询,供布点模式绑定弹窗选择) */
+export const getWindrectListNoUsed = (params?: any) => defHttp.post({ url: Api.getWindrectListNoUsed, params }, { joinParamsToUrl: true });
+
+/** 绑定 MineArea 关联关系(RequestBody:mineCode/parentId/childId;双击节点+单击节点关联时调用) */
+export const addMineAreaRelation = (params?: any) => defHttp.post({ url: Api.addMineAreaRelation, params });
+
+/** 删除 MineArea 关联关系(ids 传关系主键 id,逗号分隔/单 id;双击连线解除关系时调用) */
+export const deleteMineAreaRelation = (params?: any) => defHttp.post({ url: Api.deleteMineAreaRelation, params }, { joinParamsToUrl: true });

+ 385 - 0
backup/topology-before-arc/windTopology.data.ts

@@ -0,0 +1,385 @@
+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 = '#8c8c8c';
+export const POINT_SIZE = 16;
+
+/** 疑似隐蔽工作面巷道连线标红颜色与线宽(红色加粗,与层级配色区分) */
+export const JUDGE_COLOR = '#e53935';
+export const JUDGE_WIDTH = 6;
+
+// ==================== 测风地点层级映射 ====================
+
+/** 测风地点通风类型层级(level 字段语义),用于按层级分列绘制拓扑 */
+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)——列距固定,行距按层级分别配置 */
+export const LAYOUT = {
+  /** 画布左边距(总进点起始 x) */
+  margin: 50,
+  /** 列间距(x 轴间隔,固定) */
+  colGap: 700,
+  /** 保存布局时坐标对齐网格大小(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_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: '#333', fontWeight: 'bold' },
+            dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
+            device: { fontSize: 12, color: '#333', lineHeight: 18 },
+            dotEmpty: { fontSize: 12, color: '#d9d9d9', lineHeight: 18 },
+          },
+        },
+        label: {
+          show: true,
+          position: 'bottom',
+          fontSize: 11,
+          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' },
+    { label: '需风量(m³/min)', key: 'airVolume' },
+  ],
+};
+
+// ==================== 拓扑数据类型 ====================
+
+export interface TopoNodeData {
+  id: string;
+  /** 原始实体 id(MineArea 主键),关联操作时使用 */
+  rawId?: string;
+  name: string;
+  category: number; // categories 索引
+  parentId?: string | null;
+  isLeaf?: boolean;
+  isLeafText?: string;
+  /** 矿井编码(部门叶子节点(矿点)的 fax) */
+  fax?: string;
+  /** 测风地点通风类型层级(1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风),用于按层级分列 */
+  level?: number;
+  /** 层级显示名(levelTextMap 映射) */
+  levelText?: string;
+  airVolume?: number;
+  windrectId?: string;
+  mineCode?: string;
+  /** 布局保存坐标(用户拖动后保存的 ECharts 画布像素坐标,加载时优先使用) */
+  topologyX?: number | string;
+  topologyY?: number | string;
+  /** 疑似隐蔽工作面标记(mineArea.judgeAreaList 字段有内容 → true,对应连线标红加粗) */
+  suspected?: boolean;
+  [key: string]: any;
+}
+
+export interface TopoLinkData {
+  source: string;
+  target: string;
+  label?: string;
+  /** 连线类型:roadway=巷道连线(父巷道 → 子巷道 直线,或 地面→lv1 / lv5→地面 根边) */
+  kind?: 'roadway';
+  /** 连线子测风地点 id(roadway 边指向子级点位) */
+  pointId?: string;
+  /**
+   * 连线主要数据点位 id:进风/用风连线取子节点、回风连线取父节点。
+   * 用于连线中点标注(名称/风量/已绑定布点名)、疑似标红、布点模式绑定/解绑目标。
+   */
+  mainPointId?: string;
+  /** 对应巷道关系 id(mineAreaRelationList 项主键),双击连线解除关系时传入;根边无 */
+  relationId?: string;
+  /** 风流方向:intake=进风(左→右)/ return=回风(右→左) */
+  flow?: 'intake' | 'return';
+}
+
+export interface TopologyData {
+  nodes: TopoNodeData[];
+  links: TopoLinkData[];
+}
+
+// ==================== API 响应类型 ====================
+
+/** API -> MineArea(测风地点,level 为通风类型层级) */
+export interface MineAreaNode {
+  id: string;
+  mineCode: string;
+  name: string;
+  level: number; // 0=进风井 1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风 6=回风井
+  airVolume: number;
+  regulationId?: string;
+  windrectId?: string;
+  /** 布局保存坐标(用户拖动后保存的 ECharts 画布像素坐标) */
+  topologyX?: number | string;
+  topologyY?: number | string;
+  /** 疑似隐蔽工作面数据(有内容时该点位对应连线标红加粗;兼容历史字段 alarmList) */
+  judgeAreaList?: any;
+  createTime?: string;
+  updateTime?: string;
+  [key: string]: any;
+}
+
+/** API -> Windrect(测风装置/测风装置) */
+export interface WindrectNode {
+  id: string;
+  mineCode: string;
+  mineName?: string;
+  deviceCode?: string;
+  devicePos?: string;
+  status?: number;
+  createTime?: string;
+  [key: string]: any;
+}
+
+/** API -> MineAreaRelation(巷道关系:一条巷道 = 父巷道 → 子巷道) */
+export interface MineAreaRelationNode {
+  id: string;
+  mineCode: string;
+  parentId: string;
+  childId: string;
+  createTime?: string;
+  updateTime?: string;
+  [key: string]: any;
+}
+
+/** 关系数组元素:一条巷道(含关系主键 id,供删除),parent 为主要节点 */
+export interface AreaRelation {
+  id: string;
+  parent: MineAreaNode;
+  child: MineAreaNode;
+}
+
+/** 前端生成根节点 id:地面(总进 / 总回) */
+export const ROOT_IN_ID = 'root:in';
+export const ROOT_OUT_ID = 'root:out';
+
+/**
+ * 拓扑全量数据(getTopologyData 返回,供消费方直接使用):
+ * 接口获取数据 → 代码处理(关系/地面节点模拟/疑似标记)→ 组装为一份完整数据。
+ */
+export interface TopologyBundle {
+  /** 可直接渲染的拓扑数据(含总进地面节点、疑似标红所需信息) */
+  topology: TopologyData;
+  /** 测风装置全量列表(getWindrectList,用于解析已绑定设备显示名写入连线标注) */
+  windrectList: WindrectNode[];
+}
+
+/**
+ * 由 getMineAreaRelation 返回的 mineAreaList + mineAreaRelationList 生成关系数组。
+ * 每个元素 { id, parent, child } 描述一条巷道(父子节点信息),parent 为主要节点;
+ * 过滤 parent/child 引用缺失(脏数据)的关系。
+ */
+export function buildRelationArray(mineAreas: MineAreaNode[], relations: MineAreaRelationNode[]): AreaRelation[] {
+  const byId = new Map<string, MineAreaNode>();
+  for (const a of mineAreas) {
+    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;
+}
+
+/** 判断 mineArea 疑似字段是否有内容(非空数组/非空值),兼容历史字段名 alarmList */
+function hasJudgeContent(area: MineAreaNode): boolean {
+  const v = area.judgeAreaList !== undefined && area.judgeAreaList !== null ? area.judgeAreaList : area.alarmList;
+  if (Array.isArray(v)) return v.length > 0;
+  if (v === undefined || v === null || v === '') return false;
+  return true;
+}
+
+/**
+ * 以关系数组为数据源生成 TopologyData(每条巷道 = 父 → 子 单条直线连线):
+ * 节点:1) 总进/总回地面节点(前端生成);2) 巷道点(mineAreaList 全部记录,按 level 分列)。
+ * 边:1) 每条巷道 parent → child(roadway,携带 relationId 供删除、pointId 供标注/标红);
+ *     2) 地面(总进)→ lv1 巷道点、lv5 巷道点 → 地面(总回)(根边,无 relationId)。
+ * 节点 id 前缀(point:)保证全局唯一,过滤缺失 id 并去重。
+ */
+export function transformToTopologyData(mineAreas: MineAreaNode[], relations: AreaRelation[]): TopologyData {
+  const prefix = (cat: string, id: any) => (id === undefined || id === null || id === '' ? '' : `${cat}:${id}`);
+
+  // 巷道点:过滤缺失 id + 去重
+  const pointById = new Map<string, MineAreaNode>();
+  for (const a of mineAreas) {
+    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)→ 总回。
+  // 其余层级间的连接(lv0↔lv1、lv5↔lv6 等)由巷道关系数据驱动。
+  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,
+      mineCode: a.mineCode,
+      mineName: a.mineCode,
+      airVolume: a.airVolume,
+      windrectId: a.windrectId,
+      level: a.level,
+      levelText: levelTextMap[a.level] || '',
+      topologyX: null,
+      topologyY: null,
+      suspected: hasJudgeContent(a),
+    })),
+  ];
+
+  return { nodes, links };
+}
+
+/**
+ * 由各接口原始数据组装拓扑全量数据(供 getTopologyData 调用):
+ * 生成巷道关系数组 → 拓扑数据(含总进地面节点与疑似标记)。
+ * 任何列表为空都返回完整结构(总进地面节点 + 空列表),保证消费方总能绘制拓扑图。
+ */
+export function buildTopologyBundle(
+  mineAreaList: MineAreaNode[],
+  mineAreaRelationList: MineAreaRelationNode[],
+  windrectList: WindrectNode[]
+): TopologyBundle {
+  const relations = buildRelationArray(mineAreaList, mineAreaRelationList);
+  return {
+    topology: transformToTopologyData(mineAreaList, relations),
+    windrectList,
+  };
+}

+ 3 - 3
src/layouts/default/feature/SimpleMap.vue

@@ -3,7 +3,7 @@
   <div v-if="!isTopLevel" class="map-reset-btn">
     <Button :loading="mapLoading" type="primary" @click="revertStack">返回上级</Button>
   </div>
-  <div v-if="cadOpened" class="map-cad-btn">
+  <!-- <div v-if="cadOpened" class="map-cad-btn">
     <a-select
       v-model:value="fileId"
       :options="fileOptions"
@@ -11,7 +11,7 @@
       :style="{ minWidth: '200px' }"
       @change="toggleCADMap(true)"
     ></a-select>
-  </div>
+  </div> -->
   <!-- 省域地图监测区图例(图3-1) -->
   <!-- <div v-if="!cadOpened" class="map-legend">
     <div class="legend-title">图例</div>
@@ -407,7 +407,7 @@
   }
   .map-reset-btn {
     position: absolute;
-    right: 30%; /* 距离右边 20px */
+    right: 10%; /* 距离右边 20px */
     z-index: 2; /* 确保在地图控件之上 */
     top: @header-height;
     // padding: 10px 15px;

+ 2 - 0
src/views/analysis/warningAnalysis/windPointManage/windPointManage.api.ts

@@ -35,6 +35,8 @@ export const savePoint = (params?: any) => {
   // 供风区域:多选 id 数组 → 后端约定的对象数组 [{ id }]
   if (payload.goafList) {
     payload.goafList = payload.goafList.split(',').map((id: string) => ({ id }));
+  } else {
+    payload.goafList = null;
   }
   return defHttp.post({ url, params: payload });
 };

+ 27 - 21
src/views/analysis/warningAnalysis/windPointManage/windPointManage.data.ts

@@ -32,7 +32,7 @@ export function mineLeafDynamicRules() {
 /** 注册测风地点管理表格列 */
 export const pointColumns: BasicColumn[] = [
   {
-    title: '煤矿编号',
+    title: '煤矿名称',
     dataIndex: 'mineCode',
     width: 140,
     fixed: 'left',
@@ -66,6 +66,11 @@ export const pointColumns: BasicColumn[] = [
     dataIndex: 'airVolume',
     width: 100,
   },
+  {
+    title: '风量', // (m³/min)
+    dataIndex: 'm3Volume',
+    width: 100,
+  },
   // {
   //   title: '规程值',
   //   dataIndex: 'regulationId',
@@ -148,7 +153,7 @@ export const formSchema: FormSchema[] = [
     // 必选到矿井(叶子):必填与 isLeaf 校验由 mineLeafDynamicRules 单条规则承担
     dynamicRules: mineLeafDynamicRules,
     // 编辑态(已有 id)禁用矿编码,避免误改归属矿井;新增态保持可选
-    dynamicDisabled: ({ values }) => !!values.id,
+    ifShow: ({ values }) => !values.id,
     // 切换煤矿时清空已选测风设备,避免绑定到其他矿的设备
     componentProps: ({ formActionType }) => ({
       placeholder: '请输入煤矿编号',
@@ -160,15 +165,6 @@ export const formSchema: FormSchema[] = [
       },
     }),
   },
-  {
-    label: '测风地点名称',
-    field: 'name',
-    required: true,
-    component: 'Input',
-    componentProps: {
-      placeholder: '请输入矿井名称',
-    },
-  },
   {
     label: '层级',
     field: 'level',
@@ -187,16 +183,6 @@ export const formSchema: FormSchema[] = [
       ],
     },
   },
-  {
-    label: '需风量(m³/min)',
-    field: 'airVolume',
-    component: 'InputNumber',
-    componentProps: {
-      placeholder: '请输入需风量',
-      min: 0,
-      style: 'width: 100%',
-    },
-  },
   {
     label: '巷道分类',
     field: 'regulationId',
@@ -236,8 +222,28 @@ export const formSchema: FormSchema[] = [
       valueField: 'id',
       mode: 'multiple',
       placeholder: '请选择供风区域(可多选)',
+      placement: 'topLeft',
     }),
   },
+  {
+    label: '测风地点名称',
+    field: 'name',
+    required: true,
+    component: 'Input',
+    componentProps: {
+      placeholder: '请输入矿井名称',
+    },
+  },
+  {
+    label: '需风量(m³/min)',
+    field: 'airVolume',
+    component: 'InputNumber',
+    componentProps: {
+      placeholder: '请输入需风量',
+      min: 0,
+      style: 'width: 100%',
+    },
+  },
 ];
 
 /* ====== 判识模型管理(暂注释,待接口就绪后恢复)======

+ 3 - 3
src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue

@@ -19,7 +19,7 @@
         <a-button-group>
           <a-button @click="zoomIn" title="放大" :icon="h(PlusOutlined)"></a-button>
           <a-button @click="zoomOut" title="缩小" :icon="h(MinusOutlined)"> </a-button>
-          <a-button @click="resetView" title="复位" :icon="h(ReloadOutlined)"></a-button>
+          <!-- <a-button @click="resetView" title="复位" :icon="h(ReloadOutlined)"></a-button> -->
         </a-button-group>
         <a-divider type="vertical" />
         <!-- 双模式互斥:绑定模式(关系编辑)/ 布点模式(设备绑定) -->
@@ -119,7 +119,7 @@
   import { useTopology } from './hooks/useTopology';
   import { getWindrectListNoUsed } from './windTopology.api';
   import { h } from 'vue';
-  import { ReloadOutlined, PlusOutlined, MinusOutlined, SaveOutlined } from '@ant-design/icons-vue';
+  import { PlusOutlined, MinusOutlined, SaveOutlined } from '@ant-design/icons-vue';
 
   /** 图例层级项:按 levelTextMap/levelColorMap 顺序生成(矿井进风 → 矿井回风) */
   const levelList = Object.keys(levelTextMap).map((k) => {
@@ -150,7 +150,7 @@
     loadTopology,
     zoomIn,
     zoomOut,
-    resetView,
+    // resetView,
     clearSelection,
     saveTopologyLayout,
     dispose,