Sfoglia il codice sorgente

[Wip 0000] 测风拓扑功能开发及风速风量监测页优化

houzekong 2 settimane fa
parent
commit
49fb3f328f
19 ha cambiato i file con 1158 aggiunte e 2205 eliminazioni
  1. 14 10
      src/views/analysis/warningAnalysis/globalAlarm/globalAlarm.data.ts
  2. 14 4
      src/views/analysis/warningAnalysis/windPointManage/windPointManage.data.ts
  3. 112 232
      src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts
  4. 0 573
      src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts.bak.txt
  5. 177 49
      src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts
  6. 75 33
      src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue
  7. 0 262
      src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue.bak.txt
  8. 27 16
      src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.api.ts
  9. 0 46
      src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.api.ts.bak.txt
  10. 236 315
      src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts
  11. 0 364
      src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts.bak.txt
  12. 20 2
      src/views/monitor/sensorMonitor/index.vue
  13. 28 20
      src/views/monitor/sensorMonitor/sensorMonitor.data.ts
  14. 16 2
      src/views/monitor/surfaceMonitor/index.vue
  15. 29 10
      src/views/monitor/surfaceMonitor/surfaceMonitor.data.ts
  16. 2 1
      src/views/system/cadFile/app.ts
  17. 2 2
      src/views/system/cadFile/components/MapView.vue
  18. 15 3
      src/views/system/cadFile/components/SideDrawer.vue
  19. 391 261
      tests/useTopologyLayout.spec.ts

+ 14 - 10
src/views/analysis/warningAnalysis/globalAlarm/globalAlarm.data.ts

@@ -17,6 +17,7 @@ export const alarmTypeMap: Record<string, string> = {
   m3Lack: '风量不足',
   m3NotClosed: '风量不闭合',
   disconnect: '断线',
+  vaChange: '风速波动',
 };
 
 /** 报警全览实时监测表格列(AlarmInfo) */
@@ -48,11 +49,11 @@ export const columns: BasicColumn[] = [
     dataIndex: 'expectVal',
     width: 100,
   },
-  {
-    title: '风速波动',
-    dataIndex: 'vaChange',
-    width: 100,
-  },
+  // {
+  //   title: '风速波动',
+  //   dataIndex: 'vaChange',
+  //   width: 100,
+  // },
   {
     title: '报警时间',
     dataIndex: 'createTime',
@@ -84,11 +85,11 @@ export const historyColumns: BasicColumn[] = [
     dataIndex: 'expectVal',
     width: 100,
   },
-  {
-    title: '风速波动',
-    dataIndex: 'vaChange',
-    width: 100,
-  },
+  // {
+  //   title: '风速波动',
+  //   dataIndex: 'vaChange',
+  //   width: 100,
+  // },
   {
     title: '报警时间',
     dataIndex: 'createTime',
@@ -146,6 +147,7 @@ export const schemas: FormSchema[] = [
         { label: '风速超限', value: 'vaOverLimit' },
         { label: '风量不足', value: 'm3Lack' },
         { label: '风量不闭合', value: 'm3NotClosed' },
+        { label: '风速波动', value: 'vaChange' },
         { label: '断线', value: 'disconnect' },
       ],
     },
@@ -166,6 +168,8 @@ export const historySchemas: FormSchema[] = [
         { label: '风速超限', value: 'vaOverLimit' },
         { label: '风量不足', value: 'm3Lack' },
         { label: '风量不闭合', value: 'm3NotClosed' },
+        { label: '风速波动', value: 'vaChange' },
+        { label: '断线', value: 'disconnect' },
       ],
     },
     rules: [],

+ 14 - 4
src/views/analysis/warningAnalysis/windPointManage/windPointManage.data.ts

@@ -129,9 +129,16 @@ export const formSchema: FormSchema[] = [
     required: true,
     // 编辑态(已有 id)禁用矿编码,避免误改归属矿井;新增态保持可选
     dynamicDisabled: ({ values }) => !!values.id,
-    componentProps: {
+    // 切换煤矿时清空已选测风设备,避免绑定到其他矿的设备
+    componentProps: ({ formActionType }) => ({
       placeholder: '请输入煤矿编号',
-    },
+      changeOnSelect: true,
+      initFromStore: false,
+      syncFromStore: false,
+      onChange: () => {
+        formActionType.setFieldsValue({ windrectId: undefined });
+      },
+    }),
   },
   {
     label: '测风地点名称',
@@ -185,13 +192,16 @@ export const formSchema: FormSchema[] = [
     label: '测风设备',
     field: 'windrectId',
     component: 'ApiSelect',
-    componentProps: {
+    // 与「煤矿编号」联动:按选中矿(MineCascader 值为部门 id)过滤本矿测风设备;
+    // ApiSelect 深度监听 params 变化,切矿后自动重新请求
+    componentProps: ({ formModel }) => ({
       api: getWindrectList,
       labelField: 'devicePos',
       valueField: 'id',
       placeholder: '请输入测风设备',
       searchable: true,
-    },
+      params: formModel?.mineCode ? { deptId: formModel.mineCode } : {},
+    }),
   },
 ];
 

+ 112 - 232
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts

@@ -10,31 +10,33 @@ import {
   levelColorMap,
   nodeDetailFields,
   transformToTopologyData,
-  collectOrgSubtree,
-  roadwayColorMap,
+  buildRelationArray,
+  buildChains,
+  POINT_COLOR,
+  POINT_SIZE,
   formatAirVolumeLabel,
 } from '../windTopology.data';
-import type { TopologyData, TopoNodeData } from '../windTopology.data';
-import { getTopologyData, updateArea, deleteMineArea } from '../windTopology.api';
+import type { TopologyData, TopoNodeData, AreaChain } from '../windTopology.data';
+import { getTopologyData, updateArea } from '../windTopology.api';
 import { computeLayout } from './useTopologyLayout';
 
-/** 节点分类索引:0=部门(只读),1=测风点位,2=数据布点 */
-const CAT_POINT = 1;
-const CAT_DEVICE = 2;
-
 export function useTopology() {
   const chartRef = ref<HTMLDivElement | null>(null);
   let chartInstance: echarts.ECharts | null = null;
   let topologyData: TopologyData = { nodes: [], links: [] };
+  /** 巷道链式结构(buildChains 结果):布局时各层列内按 (链下标, 链内位置) 排序 */
+  let topologyChains: AreaChain[] = [];
+  /** 数据布点显示名映射(rawId → devicePos/deviceCode/id,取自全量 windrectList):
+   *  已绑定设备不再绘制节点,其名称由该映射写入巷道连线 edgeLabel 及解绑确认文案 */
+  let deviceNameMap: Record<string, string> = {};
   let resizeObserver: ResizeObserver | null = null;
 
   // —— 模式状态 ——
   const editMode = ref(false);
-  /** 绑定选择态:双击「测风点位/数据布点」后等待单击目标 */
-  const selectMode = ref(false);
-  const selectedSourceId = ref('');
-  /** 待绑定源节点 id(放大+描边标记,进入选择态后生效,退出后清除) */
-  const markedSourceId = ref('');
+  /** 绑定对话框:双击未绑定巷道连线后弹出,选择未使用数据布点进行绑定 */
+  const bindVisible = ref(false);
+  const bindPoint = ref<TopoNodeData | null>(null);
+  const bindDeviceId = ref<string | undefined>(undefined);
   /** 操作栏部门选择:选中任意层级部门后过滤部门树(该部门及其后代)并按该部门查询数据;空=全局 */
   const selectedDeptId = ref('');
 
@@ -83,12 +85,19 @@ export function useTopology() {
         return;
       }
       selectedDeptId.value = mine.id;
-      // 组织树层仅渲染选中的矿端节点(含矿编码 fax),不再展示整棵部门树
-      const orgNodes = collectOrgSubtree(mine);
       const apiData = await getTopologyData({ deptId: mine.id });
-      renderTopology(transformToTopologyData(orgNodes, apiData.mineAreaList, apiData.windrectList));
-      // 数据为空时给出诊断信息(矿端节点仍会渲染)
-      if (!apiData.mineAreaList.length && !apiData.windrectList.length) {
+      // 数据布点显示名映射(全量列表 → 已绑定设备的 edgeLabel/解绑文案展示用)
+      deviceNameMap = {};
+      for (const d of apiData.windrectList) {
+        if (d.id) deviceNameMap[String(d.id)] = d.devicePos || d.deviceCode || String(d.id);
+      }
+      // 巷道关系数组(每条 = 一条巷道,父子节点信息)作为拓扑数据源;
+      // 再按父子关系拆分为链式结构,供布局按链顺序排列(链间上下并行)
+      const relations = buildRelationArray(apiData.mineAreaList, apiData.mineAreaRelationList);
+      topologyChains = buildChains(relations, apiData.mineAreaList);
+      renderTopology(transformToTopologyData(apiData.mineAreaList, relations, apiData.windrectNoUsedList));
+      // 数据为空时给出诊断信息(地面节点仍会渲染)
+      if (!apiData.mineAreaList.length && !apiData.windrectNoUsedList.length) {
         console.warn('拓扑数据为空:未获取到测风点位/数据布点(deptId=' + mine.id + ')');
       }
     } catch (e) {
@@ -127,9 +136,11 @@ export function useTopology() {
 
     const cw = chartInstance.getWidth();
     const ch = chartInstance.getHeight();
-    // 通风示意图布局(不满足条件自动退级普通流程图)
-    const layout = computeLayout(data, cw, ch);
+    // 计算巷道线模型(从左到右列式)布局坐标;链式结构决定各层列内节点顺序
+    const layout = computeLayout(data, cw, ch, topologyChains);
     const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
+    // roadway 边子节点(名称已在连线中点展示,节点自身不再显示文本标签)
+    const roadwayChildIds = new Set(data.links.filter((l) => l.kind === 'roadway' && l.pointId).map((l) => l.pointId));
 
     // 最终按 id 去重兜底(数据层已去重,此处防止异常数据导致 ECharts 重复 id 报错)
     const seenIds = new Set<string>();
@@ -141,26 +152,17 @@ export function useTopology() {
       }
       seenIds.add(n.id);
       const cat = categories[n.category] || categories[0];
-      const isMarked = n.id === markedSourceId.value;
-      // 节点配色按类型区分:
-      //   测风点位(category 1)按通风层级 levelColorMap 配色,无 level 回退分类色;
-      //   数据布点(category 2)按状态配色(正常绿/离线灰);
-      //   部门/矿端(category 0)使用分类色(紫色),不再被 normal 状态色统一覆盖
+      // 节点配色:测风点位统一灰色小圆点(层级/类型区分转移到连线颜色);
+      // 数据布点(category 2)按状态配色(正常绿/离线灰);地面节点(category 0)使用分类色
       let nodeColor = cat.color;
       if (n.category === 1) {
-        nodeColor = levelColorMap[Number(n.level)] || cat.color;
+        nodeColor = POINT_COLOR;
       } else if (n.category === 2) {
         nodeColor = statusColorMap[n.status || 'normal'] || cat.color;
       }
       const itemStyle: any = { color: nodeColor };
-      // 待绑定源节点:放大 + 红色描边,让用户明确当前正在操作哪个节点
-      if (isMarked) {
-        itemStyle.borderColor = '#ff4d4f';
-        itemStyle.borderWidth = 3;
-      }
-      // 测风点位:非用风(L3)点位以末端小点呈现(巷道连线末端),L3 用风保持节点样式
-      const isEndDot = n.category === 1 && Number(n.level) !== 3;
-      if (isEndDot) {
+      // 测风点位统一为灰色小圆点(白描边衬底,避免与彩色连线粘连)
+      if (n.category === 1) {
         itemStyle.borderColor = '#ffffff';
         itemStyle.borderWidth = 1.5;
       }
@@ -170,11 +172,15 @@ export function useTopology() {
         name: n.name,
         category: n.category,
         value: n.name,
-        symbol: isEndDot ? 'circle' : cat.symbol,
-        symbolSize: isEndDot ? 8 : isMarked ? cat.symbolSize + 8 : cat.symbolSize,
+        symbol: n.category === 1 ? 'circle' : cat.symbol,
+        symbolSize: n.category === 1 ? POINT_SIZE : cat.symbolSize,
         itemStyle,
         raw: n,
       };
+      // 巷道连线子节点:名称已在连线中点标注展示,节点本身不再显示文本标签(避免重复)
+      if (n.category === 1 && roadwayChildIds.has(n.id)) {
+        en.label = { show: false };
+      }
       if (pos) {
         en.x = pos.x;
         en.y = pos.y;
@@ -184,14 +190,12 @@ export function useTopology() {
     }
 
     // 绘制层级:同一 series 内后绘制者在上。按优先级排序使「数据布点」置于最底、「测风点位」在其上方,
-    // 避免布点(roundRect)压住与其相邻/重叠的测风点位或连线末端;部门/矿井保持最底绘制
+    // 避免布点(roundRect)压住与其相邻/重叠的测风点位或连线;地面保持最底绘制
     const renderPriority: Record<number, number> = { 0: 0, 2: 1, 1: 2 };
     echartsNodes.sort((a, b) => (renderPriority[a.category] ?? 0) - (renderPriority[b.category] ?? 0));
     series.nodes = echartsNodes;
     series.links = data.links
       .map((l) => {
-        // 线性列式布局不绘制回风闭合边(L5→矿井,无 pointId 的 roadway 边)
-        if (l.kind === 'roadway' && !l.pointId && layout.mode === 'linear') return null;
         const link: any = {
           source: l.source,
           target: l.target,
@@ -200,27 +204,27 @@ export function useTopology() {
           flow: l.flow,
         };
         if (l.kind === 'roadway') {
-          // 巷道连线:进风/回风分色 + 风流方向箭头 + 轻微曲线;连线标注所属测风点位风量
+          // 巷道连线:直线(无曲率)+ 按子节点 level 用 levelColorMap 着色(颜色区分转移到连线上,
+          // 含 地面→lv1 / lv5→地面 根边,pointId 指向节点即着色依据)+ 风流方向箭头;
+          // 中点标注(edgeLabel rich 富文本):第一行点位名称、第二行风量、第三行圆点 + 已绑定设备名;
+          // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
           const point = l.pointId ? pointById.get(l.pointId) : undefined;
-          link.flowLabel = formatAirVolumeLabel(point?.airVolume);
+          const lv = point ? Number(point.level) : NaN;
+          const deviceName = point?.windrectId ? deviceNameMap[point.windrectId] : '';
+          link.midLabel = point
+            ? `{name|${point.name}}\n{volume|${formatAirVolumeLabel(point.airVolume)}}\n${
+                deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'
+              }`
+            : '';
           link.lineStyle = {
-            color: roadwayColorMap[l.flow || 'intake'],
+            color: levelColorMap[lv] || POINT_COLOR,
             width: 3,
             opacity: 0.85,
-            curveness: 0.2,
+            curveness: 0,
             ...(editMode.value ? { type: 'dashed' as const } : {}),
           };
           link.edgeSymbol = ['none', 'arrow'];
           link.edgeSymbolSize = [0, 10];
-        } else if (l.kind === 'sensor') {
-          // 数据布点短连线(可双击解绑)
-          link.lineStyle = {
-            color: '#bfbfbf',
-            width: 1.5,
-            opacity: 0.9,
-            curveness: 0,
-            ...(editMode.value ? { type: 'dashed' as const } : {}),
-          };
         } else {
           link.lineStyle = { color: '#8c8c8c', width: 1.5, opacity: 0.6, curveness: 0 };
         }
@@ -246,42 +250,17 @@ export function useTopology() {
 
     // ——— 单击 ———
     chartInstance.on('click', (params: any) => {
-      // 待绑定态下单击空白处 → 取消选择
-      if (!params.data) {
-        if (selectMode.value) {
-          exitSelectMode();
-          message.info('已取消绑定');
-        }
-        return;
-      }
+      if (!params.data) return;
 
       if (params.dataType === 'node') {
-        if (editMode.value && selectMode.value) {
-          // 选择态下单击节点 → 尝试绑定(仅测风点位为绑定目标)
-          if (params.data.category === CAT_POINT) {
-            attemptBind(selectedSourceId.value, params.data.id);
-          } else {
-            message.warning('请单击巷道连线或用风节点作为绑定目标');
-          }
-          return;
-        }
-        // 普通单击 → 显示详情
+        // 单击节点 → 显示详情
         const raw = params.data.raw as TopoNodeData;
         if (raw) showNodeDetail(raw);
         return;
       }
 
       if (params.dataType === 'edge') {
-        if (editMode.value && selectMode.value) {
-          // 选择态下单击巷道连线 → 绑定到其所属测风点位
-          if (params.data.kind === 'roadway' && params.data.pointId) {
-            attemptBind(selectedSourceId.value, params.data.pointId);
-          } else {
-            message.warning('请单击巷道连线或用风节点作为绑定目标');
-          }
-          return;
-        }
-        // 普通单击巷道连线 → 显示其所属测风点位详情(连线即测风点位)
+        // 单击巷道连线 → 显示其所属测风点位详情(连线即测风点位)
         if (params.data.kind === 'roadway' && params.data.pointId) {
           const point = topologyData.nodes.find((n) => n.id === params.data.pointId);
           if (point) showNodeDetail(point);
@@ -292,36 +271,25 @@ export function useTopology() {
     // ——— 双击 ———
     chartInstance.on('dblclick', (params: any) => {
       if (!params.data) return;
-      // 双击交互(删除/绑定/解绑)仅在绑定模式下生效
+      // 双击交互(绑定/解绑)仅在绑定模式下生效
       if (!editMode.value) return;
 
-      if (params.dataType === 'edge') {
-        if (params.data.kind === 'roadway') {
-          // 双击巷道连线 → 删除该测风点位
-          handleDeletePoint(params.data.pointId);
-          return;
-        }
-        if (params.data.kind === 'sensor') {
-          // 双击数据布点短连线 → 解绑
-          handleUnbind(params.data.source, params.data.target, params.data.kind);
-          return;
+      if (params.dataType === 'edge' && params.data.kind === 'roadway' && params.data.pointId) {
+        // 双击巷道连线 → 对其所属测风点位绑定/解绑:
+        // 已绑定(windrectId 非空)→ 解绑;未绑定 → 弹出对话框选择数据布点绑定
+        const point = topologyData.nodes.find((n) => n.id === params.data.pointId);
+        if (!point) return;
+        if (point.windrectId) {
+          bindPoint.value = point;
+          confirmUnbind();
+        } else {
+          bindPoint.value = point;
+          bindDeviceId.value = undefined;
+          bindVisible.value = true;
         }
         return;
       }
-
-      if (params.dataType === 'node') {
-        if (params.data.category === CAT_POINT) {
-          // 双击测风点位(用风节点 / 流程图模式点位节点)→ 删除
-          handleDeletePoint(params.data.id);
-          return;
-        }
-        if (params.data.category === CAT_DEVICE) {
-          // 双击数据布点 → 作为绑定源
-          enterSelectMode(params.data.id);
-          return;
-        }
-        message.info('矿井节点不可删除');
-      }
+      // 双击节点:无操作(已去除双击节点操作)
     });
 
     // ——— 拖拽结束 ———
@@ -331,149 +299,57 @@ export function useTopology() {
     });
   }
 
-  // ==================== 选择目标态 ====================
-
-  function enterSelectMode(sourceId: string) {
-    const src = topologyData.nodes.find((n) => n.id === sourceId);
-    if (!src) return;
-    if (src.category !== CAT_DEVICE) {
-      message.info('请双击「数据布点」节点作为绑定源');
-      return;
-    }
-    selectMode.value = true;
-    selectedSourceId.value = sourceId;
-    markedSourceId.value = sourceId;
-    // 重绘应用放大 + 描边标记,并高亮关联连线
-    renderTopology(topologyData);
-    highlightPath(sourceId);
-    message.info(`已选中"${src.name}",请单击一个巷道连线或用风节点绑定。单击空白处取消。`);
-  }
-
-  function exitSelectMode() {
-    selectMode.value = false;
-    selectedSourceId.value = '';
-    markedSourceId.value = '';
-    chartInstance?.dispatchAction({ type: 'downplay' });
-    // 重绘移除待绑定源节点的放大/描边标记
-    renderTopology(topologyData);
-  }
-
-  // ==================== 绑定逻辑 ====================
+  // ==================== 绑定/解绑逻辑 ====================
 
-  /** 按数据布点原始 id 解析其显示名(用于确认文案,避免直接展示 id) */
+  /** 按数据布点原始 id 解析其显示名(用于确认文案,避免直接展示 id;设备节点不再绘制,改用全量列表映射) */
   function deviceNameOf(rawId?: string) {
-    const dev = topologyData.nodes.find((n) => n.category === CAT_DEVICE && n.rawId === rawId);
-    return dev?.name || rawId || '';
-  }
-
-  /** 统一确认弹窗并执行编辑(一次 updateArea 调用,后端负责用新 id 替换旧绑定) */
-  function confirmBind(confirmText: string, params: any) {
-    Modal.confirm({
-      title: '确认绑定',
-      content: confirmText,
-      okText: '确认绑定',
-      cancelText: '取消',
-      onOk: async () => {
-        try {
-          await updateArea(params);
-          await loadTopology();
-          message.success('绑定成功');
-        } catch {
-          message.error('绑定失败');
-        }
-      },
-    });
+    return (rawId && deviceNameMap[rawId]) || rawId || '';
   }
 
-  /**
-   * 绑定/换绑:数据布点(源)→ 测风点位(目标),编辑测风点位的 windrectId
-   * (后端一次调用替换旧绑定);目标点位可由巷道连线(roadway 边 pointId)或用风节点解析。
-   */
-  function attemptBind(sourceId: string, pointId: string) {
-    exitSelectMode();
-    if (sourceId === pointId) {
-      message.warning('不能绑定到自身');
-      return;
-    }
-    const src = topologyData.nodes.find((n) => n.id === sourceId);
-    const tgt = topologyData.nodes.find((n) => n.id === pointId);
-    if (!src || !tgt) return;
-
-    // 仅支持:数据布点(源)→ 测风点位(目标)
-    if (src.category !== CAT_DEVICE || tgt.category !== CAT_POINT) {
-      message.warning('仅支持「数据布点」绑定到「测风点位」');
-      return;
-    }
-    const alreadyLinked = topologyData.links.some((l) => l.kind === 'sensor' && l.source === tgt.id && l.target === src.id);
-    if (alreadyLinked) {
-      message.warning('该数据布点已绑定到该测风点位');
-      return;
-    }
-    const oldDeviceName = deviceNameOf(tgt.windrectId);
-    const confirmText = tgt.windrectId
-      ? `该测风点位已绑定数据布点"${oldDeviceName}",确认改绑为"${src.name}"?`
-      : `确认将"${src.name}"绑定到测风点位"${tgt.name}"?`;
-    confirmBind(confirmText, { id: tgt.rawId, windrectId: src.rawId });
+  /** 关闭绑定对话框(index.vue 取消/关闭时同步,成功关闭由 confirmBind 内部调用) */
+  function closeBindDialog() {
+    bindVisible.value = false;
   }
 
-  // ==================== 删除测风点位 ====================
-
-  /** 双击巷道连线/用风节点删除测风点位(绑定模式下),调 deleteMineArea 删除原始记录 */
-  function handleDeletePoint(pointId?: string) {
-    if (!pointId) {
-      message.warning('该连线不可删除');
+  /** 绑定:对话框选择数据布点后调用(一次 updateArea 调用更新 windrectId 字段,与现有绑定逻辑一致) */
+  async function confirmBind(deviceId?: string) {
+    const point = bindPoint.value;
+    if (!point?.rawId) {
+      message.warning('缺少测风点位原始 id,无法绑定');
       return;
     }
-    const point = topologyData.nodes.find((n) => n.id === pointId);
-    if (!point || point.category !== CAT_POINT) {
-      message.warning('该连线不可删除');
+    if (!deviceId) {
+      message.warning('请选择数据点位');
       return;
     }
-    if (!point.rawId) {
-      message.warning('缺少测风点位原始 id,无法删除');
-      return;
+    try {
+      await updateArea({ id: point.rawId, windrectId: deviceId });
+      closeBindDialog();
+      await loadTopology();
+      message.success('绑定成功');
+    } catch {
+      message.error('绑定失败');
     }
-    Modal.confirm({
-      title: '确认删除',
-      content: `确定删除测风点位「${point.name}」?其绑定的数据布点将解除绑定。`,
-      okText: '确认删除',
-      okType: 'danger',
-      cancelText: '取消',
-      onOk: async () => {
-        try {
-          await deleteMineArea({ ids: point.rawId });
-          await loadTopology();
-          message.success('删除成功');
-        } catch {
-          message.error('删除失败');
-        }
-      },
-    });
   }
 
-  // ==================== 解绑逻辑 ====================
-
-  async function handleUnbind(sourceId: string, targetId: string, kind?: string) {
-    // 仅允许删除「测风点位↔数据布点」的绑定关系
-    if (kind !== 'sensor') {
-      message.warning('仅允许删除「测风点位」与「数据布点」的绑定关系');
-      return;
-    }
-    const src = topologyData.nodes.find((n) => n.id === sourceId);
-    const tgt = topologyData.nodes.find((n) => n.id === targetId);
+  /** 解绑:双击已绑定巷道连线后确认,清空测风点位 windrectId 字段(与现有解绑逻辑一致) */
+  function confirmUnbind() {
+    const point = bindPoint.value;
+    if (!point) return;
+    const deviceName = deviceNameOf(point.windrectId);
     Modal.confirm({
       title: '确认解绑',
-      content: `确定解除"${src?.name || sourceId}" → "${tgt?.name || targetId}"的绑定关系?`,
+      content: `确定解除"${point.name}"与数据布点"${deviceName}"的绑定关系?`,
       okText: '确认解绑',
       cancelText: '取消',
       onOk: async () => {
+        if (!point.rawId) {
+          message.warning('缺少测风点位原始 id,无法解绑');
+          return;
+        }
         try {
-          // 解绑即清空测风点位(source 为测风点位)的 windrectId,用原始实体 id 调用接口
-          if (!src?.rawId) {
-            message.warning('缺少测风点位原始 id,无法解绑');
-            return;
-          }
-          await updateArea({ id: src.rawId, windrectId: '' });
+          // 解绑即清空测风点位的 windrectId,用原始实体 id 调用接口
+          await updateArea({ id: point.rawId, windrectId: '' });
           await loadTopology();
           message.success('解绑成功');
         } catch {
@@ -498,7 +374,6 @@ export function useTopology() {
   function clearSelection() {
     selectedNode.value = null;
     detailFields.value = [];
-    exitSelectMode();
   }
 
   // ==================== 高亮路径 ====================
@@ -553,12 +428,11 @@ export function useTopology() {
   function setEditMode(on: boolean) {
     editMode.value = on;
     if (!chartInstance) return;
-    exitSelectMode();
 
     if (on) {
       renderTopology(topologyData, true);
       chartInstance.setOption({ series: [{ roam: false }] as any });
-      message.info('绑定模式已开启:双击「数据布点」→ 单击巷道连线/末端小点或用风节点绑定/换绑;双击巷道连线/用风节点删除测风点位;双击短连线解绑');
+      message.info('绑定模式已开启:双击巷道连线→未绑定则选择数据点位绑定,已绑定则解绑');
     } else {
       renderTopology(topologyData);
       message.info('已退出绑定模式');
@@ -582,6 +456,12 @@ export function useTopology() {
   return {
     chartRef,
     editMode,
+    bindVisible,
+    bindPoint,
+    bindDeviceId,
+    confirmBind,
+    closeBindDialog,
+    confirmUnbind,
     selectedNode,
     detailFields,
     selectedDeptId,

+ 0 - 573
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts.bak.txt

@@ -1,573 +0,0 @@
-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,
-  statusColorMap,
-  levelColorMap,
-  nodeDetailFields,
-  weightKey,
-  transformToTopologyData,
-  collectOrgSubtree,
-  LAYOUT_WIDTH,
-  LAYOUT_HEIGHT,
-} from '../windTopology.data';
-import type { TopologyData, TopoNodeData } from '../windTopology.data';
-import { getTopologyData, updateArea } from '../windTopology.api';
-
-/** 节点分类索引:0=部门(只读),1=测风点位,2=数据布点 */
-const CAT_ORG = 0;
-const CAT_POINT = 1;
-const CAT_DEVICE = 2;
-
-export function useTopology() {
-  const chartRef = ref<HTMLDivElement | null>(null);
-  let chartInstance: echarts.ECharts | null = null;
-  let topologyData: TopologyData = { nodes: [], links: [] };
-  let resizeObserver: ResizeObserver | null = null;
-
-  // —— 模式状态 ——
-  const editMode = ref(false);
-  /** 绑定选择态:双击「测风点位/数据布点」后等待单击目标 */
-  const selectMode = ref(false);
-  const selectedSourceId = ref('');
-  /** 待绑定源节点 id(放大+描边标记,进入选择态后生效,退出后清除) */
-  const markedSourceId = ref('');
-  /** 操作栏部门选择:选中任意层级部门后过滤部门树(该部门及其后代)并按该部门查询数据;空=全局 */
-  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;
-  }
-
-  // ==================== 三层布局 ====================
-
-  function applyHierarchicalLayout(echartsNodes: any[], forceUnfixed = false, chartW = 1200, chartH = 700) {
-    // 容器过小/未初始化时用参考尺寸布局,避免节点全部落在画布外
-    if (chartW < 100) chartW = LAYOUT_WIDTH;
-    if (chartH < 100) chartH = LAYOUT_HEIGHT;
-
-    // 行序列:部门节点按树层级(depth)逐层分组为多行;测风点位按通风层级(level 1~5)各占一行;
-    // 数据布点占最后一行(缺失 level 的测风点位归入 level=0 行,紧随部门行之后)
-    const rows: { list: any[]; weight?: string }[] = [];
-    const depthGroups: Record<number, any[]> = {};
-    const levelGroups: Record<number, any[]> = {};
-    const catGroups: Record<number, any[]> = {};
-    for (const en of echartsNodes) {
-      if (en.category === 0) {
-        const depth = en.raw?.depth ?? 0;
-        if (!depthGroups[depth]) depthGroups[depth] = [];
-        depthGroups[depth].push(en);
-      } else if (en.category === 1) {
-        const level = Number(en.raw?.level) || 0;
-        if (!levelGroups[level]) levelGroups[level] = [];
-        levelGroups[level].push(en);
-      } else {
-        if (!catGroups[en.category]) catGroups[en.category] = [];
-        catGroups[en.category].push(en);
-      }
-    }
-    const depths = Object.keys(depthGroups)
-      .map(Number)
-      .sort((a, b) => a - b);
-    for (const d of depths) rows.push({ list: depthGroups[d] });
-    // 测风点位按层级 1~5 依次分行(level 0 为未分级,排在最前)
-    const levels = Object.keys(levelGroups)
-      .map(Number)
-      .sort((a, b) => a - b);
-    for (const l of levels) rows.push({ list: levelGroups[l], weight: weightKey[1] });
-    if (catGroups[2]) rows.push({ list: catGroups[2], weight: weightKey[2] });
-
-    const rowCount = rows.length || 1;
-    const topMargin = 40;
-    const bottomMargin = 40;
-    // 层级行间距(加宽间隔,可调)
-    const rowGap = 60;
-    const rowHeight = (chartH - topMargin - bottomMargin - rowGap * (rowCount - 1)) / rowCount;
-    const margin = 60;
-    const tw = chartW - margin * 2;
-
-    rows.forEach((row, idx) => {
-      const list = row.list;
-      if (!list.length) return;
-      // 行内排序(按权重字段,值越大越靠左)
-      const wk = row.weight;
-      list.sort((a, b) => {
-        const va = wk ? Number(a.raw?.[wk]) || 0 : 0;
-        const vb = wk ? Number(b.raw?.[wk]) || 0 : 0;
-        return vb - va;
-      });
-      const gap = list.length > 1 ? tw / (list.length - 1) : 0;
-      const sx = margin + (tw - (list.length - 1) * gap) / 2;
-      const y = topMargin + idx * (rowHeight + rowGap) + rowHeight / 2;
-      list.forEach((en, i) => {
-        en.x = sx + i * gap;
-        en.y = y;
-        en.fixed = !forceUnfixed;
-      });
-    });
-  }
-
-  // —— 加载并渲染拓扑 ——
-  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;
-      // 组织树层仅渲染选中的矿端节点(含矿编码 fax),不再展示整棵部门树
-      const orgNodes = collectOrgSubtree(mine);
-      const apiData = await getTopologyData({ deptId: mine.id });
-      renderTopology(transformToTopologyData(orgNodes, apiData.mineAreaList, apiData.windrectList));
-      // 数据为空时给出诊断信息(矿端节点仍会渲染)
-      if (!apiData.mineAreaList.length && !apiData.windrectList.length) {
-        console.warn('拓扑数据为空:未获取到测风点位/数据布点(deptId=' + mine.id + ')');
-      }
-    } 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, forceUnfixed = false) {
-    topologyData = data;
-    if (!chartInstance) return;
-
-    const option: EChartsOption = createGraphOption();
-    const series: any = (option.series as any[])[0];
-
-    // 最终按 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];
-      const isMarked = n.id === markedSourceId.value;
-      // 节点配色按类型区分:
-      //   测风点位(category 1)按通风层级 levelColorMap 配色,无 level 回退分类色;
-      //   数据布点(category 2)按状态配色(正常绿/离线灰);
-      //   部门/矿端(category 0)使用分类色(紫色),不再被 normal 状态色统一覆盖
-      let nodeColor = cat.color;
-      if (n.category === 1) {
-        nodeColor = levelColorMap[n.level] || cat.color;
-      } else if (n.category === 2) {
-        nodeColor = statusColorMap[n.status || 'normal'] || cat.color;
-      }
-      const itemStyle: any = { color: nodeColor };
-      // 待绑定源节点:放大 + 红色描边,让用户明确当前正在操作哪个节点
-      if (isMarked) {
-        itemStyle.borderColor = '#ff4d4f';
-        itemStyle.borderWidth = 3;
-      }
-      echartsNodes.push({
-        id: n.id,
-        name: n.name,
-        category: n.category,
-        value: n.name,
-        symbol: cat.symbol,
-        symbolSize: isMarked ? cat.symbolSize + 8 : cat.symbolSize,
-        itemStyle,
-        raw: n,
-      });
-    }
-
-    const cw = chartInstance.getWidth();
-    const ch = chartInstance.getHeight();
-    applyHierarchicalLayout(echartsNodes, forceUnfixed, cw, ch);
-    series.nodes = echartsNodes;
-    series.links = data.links.map((l) => ({
-      source: l.source,
-      target: l.target,
-      kind: l.kind,
-    }));
-
-    if (editMode.value) {
-      series.lineStyle = { ...series.lineStyle, type: 'dashed' as const, width: 2, opacity: 0.6 };
-    }
-
-    chartInstance.setOption(option, true);
-    registerEvents();
-  }
-
-  // ==================== 交互事件 ====================
-
-  function registerEvents() {
-    if (!chartInstance) return;
-    chartInstance.off('click');
-    chartInstance.off('dblclick');
-    chartInstance.off('dragend');
-
-    // ——— 单击 ———
-    chartInstance.on('click', (params: any) => {
-      // 待绑定态下单击空白处 → 取消选择
-      if (!params.data) {
-        if (selectMode.value) {
-          exitSelectMode();
-          message.info('已取消绑定');
-        }
-        return;
-      }
-
-      if (params.dataType === 'node') {
-        if (editMode.value && selectMode.value) {
-          // 选择态下单击另一节点 → 尝试绑定
-          attemptBind(selectedSourceId.value, params.data.id);
-          return;
-        }
-        // 普通单击 → 显示详情
-        const raw = params.data.raw as TopoNodeData;
-        if (raw) showNodeDetail(raw);
-      }
-    });
-
-    // ——— 双击 ———
-    chartInstance.on('dblclick', (params: any) => {
-      if (!params.data) return;
-
-      if (params.dataType === 'edge' && editMode.value) {
-        // 双击连线 → 解绑(仅测风点位↔数据布点)
-        handleUnbind(params.data.source, params.data.target, params.data.kind);
-        return;
-      }
-
-      if (params.dataType === 'node' && editMode.value) {
-        // 「测风点位」作为绑定到矿井的源;「数据布点」作为绑定到测风点位的源
-        if (params.data.category === CAT_POINT || params.data.category === CAT_DEVICE) {
-          enterSelectMode(params.data.id);
-        } else {
-          message.info('请双击「测风点位」或「数据布点」节点作为绑定源');
-        }
-      }
-    });
-
-    // ——— 拖拽结束 ———
-    chartInstance.on('dragend', (params: any) => {
-      if (!params.data || params.dataType !== 'node') return;
-      highlightPath(params.data.id);
-    });
-  }
-
-  // ==================== 选择目标态 ====================
-
-  function enterSelectMode(sourceId: string) {
-    const src = topologyData.nodes.find((n) => n.id === sourceId);
-    if (!src) return;
-    if (src.category !== CAT_POINT && src.category !== CAT_DEVICE) {
-      message.info('请双击「测风点位」或「数据布点」节点作为绑定源');
-      return;
-    }
-    selectMode.value = true;
-    selectedSourceId.value = sourceId;
-    markedSourceId.value = sourceId;
-    // 重绘应用放大 + 描边标记,并高亮关联连线
-    renderTopology(topologyData);
-    highlightPath(sourceId);
-    const tip =
-      src.category === CAT_POINT
-        ? `已选中"${src.name}",请单击一个「矿井」或「数据布点」绑定。单击空白处取消。`
-        : `已选中"${src.name}",请单击一个「测风点位」绑定。单击空白处取消。`;
-    message.info(tip);
-  }
-
-  function exitSelectMode() {
-    selectMode.value = false;
-    selectedSourceId.value = '';
-    markedSourceId.value = '';
-    chartInstance?.dispatchAction({ type: 'downplay' });
-    // 重绘移除待绑定源节点的放大/描边标记
-    renderTopology(topologyData);
-  }
-
-  // ==================== 绑定逻辑 ====================
-
-  /** 按数据布点原始 id 解析其显示名(用于确认文案,避免直接展示 id) */
-  function deviceNameOf(rawId?: string) {
-    const dev = topologyData.nodes.find((n) => n.category === CAT_DEVICE && n.rawId === rawId);
-    return dev?.name || rawId || '';
-  }
-
-  /** 统一确认弹窗并执行编辑(一次 updateArea 调用,后端负责用新 id 替换旧绑定) */
-  function confirmBind(confirmText: string, params: any) {
-    Modal.confirm({
-      title: '确认绑定',
-      content: confirmText,
-      okText: '确认绑定',
-      cancelText: '取消',
-      onOk: async () => {
-        try {
-          await updateArea(params);
-          await loadTopology();
-          message.success('绑定成功');
-        } catch {
-          message.error('绑定失败');
-        }
-      },
-    });
-  }
-
-  async function attemptBind(sourceId: string, targetId: string) {
-    exitSelectMode();
-    if (sourceId === targetId) {
-      message.warning('不能绑定到自身');
-      return;
-    }
-    const src = topologyData.nodes.find((n) => n.id === sourceId);
-    const tgt = topologyData.nodes.find((n) => n.id === targetId);
-    if (!src || !tgt) return;
-
-    // 测风点位 → 矿井:编辑测风点位的 mineCode(允许改绑)
-    if (src.category === CAT_POINT && tgt.category === CAT_ORG) {
-      if (!tgt.isLeaf) {
-        message.warning('仅支持绑定到矿井(叶子节点)');
-        return;
-      }
-      const fax = tgt.fax || '';
-      if (!fax) {
-        message.warning('目标矿井缺少矿编码,无法绑定');
-        return;
-      }
-      if (src.mineCode === fax) {
-        message.warning('该测风点位已绑定到该矿');
-        return;
-      }
-      const confirmText = src.mineCode
-        ? `该测风点位已绑定到矿编码"${src.mineCode}",确认改绑到"${tgt.name}"?`
-        : `确认将"${src.name}"绑定到"${tgt.name}"?`;
-      confirmBind(confirmText, { id: src.rawId, mineCode: fax });
-      return;
-    }
-
-    // 测风点位 → 数据布点 / 数据布点 → 测风点位:均编辑测风点位的 windrectId(后端一次调用替换旧绑定)
-    let pointNode: TopoNodeData;
-    let deviceNode: TopoNodeData;
-    if (src.category === CAT_POINT && tgt.category === CAT_DEVICE) {
-      pointNode = src;
-      deviceNode = tgt;
-    } else if (src.category === CAT_DEVICE && tgt.category === CAT_POINT) {
-      pointNode = tgt;
-      deviceNode = src;
-    } else {
-      message.warning('仅支持「测风点位→矿井/数据布点」或「数据布点→测风点位」的绑定');
-      return;
-    }
-
-    const alreadyLinked = topologyData.links.some((l) => l.kind === 'sensor' && l.source === pointNode.id && l.target === deviceNode.id);
-    if (alreadyLinked) {
-      message.warning('已存在该绑定关系');
-      return;
-    }
-    const oldDeviceName = deviceNameOf(pointNode.windrectId);
-    const confirmText = pointNode.windrectId
-      ? `该测风点位已绑定数据布点"${oldDeviceName}",确认改绑到"${deviceNode.name}"?`
-      : `确认将"${deviceNode.name}"绑定到测风点位"${pointNode.name}"?`;
-    confirmBind(confirmText, { id: pointNode.rawId, windrectId: deviceNode.rawId });
-  }
-
-  // ==================== 解绑逻辑 ====================
-
-  async function handleUnbind(sourceId: string, targetId: string, kind?: string) {
-    // 仅允许删除「测风点位↔数据布点」的绑定关系
-    if (kind !== 'sensor') {
-      message.warning('仅允许删除「测风点位」与「数据布点」的绑定关系');
-      return;
-    }
-    const src = topologyData.nodes.find((n) => n.id === sourceId);
-    const tgt = topologyData.nodes.find((n) => n.id === targetId);
-    Modal.confirm({
-      title: '确认解绑',
-      content: `确定解除"${src?.name || sourceId}" → "${tgt?.name || targetId}"的绑定关系?`,
-      okText: '确认解绑',
-      cancelText: '取消',
-      onOk: async () => {
-        try {
-          // 解绑即清空测风点位(source 为测风点位)的 windrectId,用原始实体 id 调用接口
-          if (!src?.rawId) {
-            message.warning('缺少测风点位原始 id,无法解绑');
-            return;
-          }
-          await updateArea({ id: src.rawId, windrectId: '' });
-          await loadTopology();
-          message.success('解绑成功');
-        } catch {
-          message.error('解绑失败');
-        }
-      },
-    });
-  }
-
-  // ==================== 节点详情 ====================
-
-  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 = [];
-    exitSelectMode();
-  }
-
-  // ==================== 高亮路径 ====================
-
-  function highlightPath(nodeId: string) {
-    if (!chartInstance) return;
-    const s = (chartInstance.getOption().series as any[])[0];
-    const nodes = s.data || [];
-    const links = s.links || [];
-    // 单个高亮派发失败不拖垮页面
-    const safeDispatch = (action: any) => {
-      try {
-        chartInstance?.dispatchAction(action);
-      } catch (e) {
-        console.warn('高亮派发失败:', e);
-      }
-    };
-    safeDispatch({ type: 'downplay' });
-    // 按节点 id 定位 dataIndex 高亮(name 是显示名,不能用作定位)
-    const nodeIndex = nodes.findIndex((d: any) => d && d.id === nodeId);
-    if (nodeIndex >= 0) {
-      safeDispatch({ type: 'highlight', seriesIndex: 0, dataIndex: nodeIndex, dataType: 'node' });
-    }
-    const edgeIndices: number[] = [];
-    links.forEach((l: any, i: number) => {
-      if (l.source === nodeId || l.target === nodeId) edgeIndices.push(i);
-    });
-    edgeIndices.forEach((i) => safeDispatch({ type: 'highlight', seriesIndex: 0, dataIndex: i, dataType: 'edge' }));
-  }
-
-  // ==================== 缩放 ====================
-
-  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 setEditMode(on: boolean) {
-    editMode.value = on;
-    if (!chartInstance) return;
-    exitSelectMode();
-
-    if (on) {
-      renderTopology(topologyData, true);
-      chartInstance.setOption({ series: [{ roam: false }] as any });
-      message.info('绑定模式已开启:双击「测风点位」→ 单击「矿井」或「数据布点」绑定;双击「数据布点」→ 单击「测风点位」绑定;双击连线解绑');
-    } else {
-      renderTopology(topologyData);
-      message.info('已退出绑定模式');
-    }
-  }
-
-  // ==================== resize / dispose ====================
-
-  function handleResize() {
-    chartInstance?.resize();
-  }
-
-  function dispose() {
-    resizeObserver?.disconnect();
-    resizeObserver = null;
-    window.removeEventListener('resize', handleResize);
-    chartInstance?.dispose();
-    chartInstance = null;
-  }
-
-  return {
-    chartRef,
-    editMode,
-    selectedNode,
-    detailFields,
-    selectedDeptId,
-    setSelectedDept,
-    initChart,
-    loadTopology,
-    zoomIn,
-    zoomOut,
-    resetView,
-    setEditMode,
-    clearSelection,
-    dispose,
-  };
-}

+ 177 - 49
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts

@@ -1,39 +1,63 @@
-import type { TopologyData, TopoNodeData } from '../windTopology.data';
-import { LAYOUT, LAYOUT_WIDTH, LAYOUT_HEIGHT } from '../windTopology.data';
-
-/** 布局模式:linear=从左到右列式布局(矿井最左,各层级一列,未分级最右,不闭合) */
-export type LayoutMode = 'linear';
-
-/** 数据布点放置方式:node-side=点位正下方 / bottom=图下方 */
-export type DevicePlacementType = 'node-side' | 'bottom';
+import type { TopologyData, TopoNodeData, AreaChain } from '../windTopology.data';
+import { LAYOUT, LAYOUT_WIDTH, LAYOUT_HEIGHT, ROOT_IN_ID, ROOT_OUT_ID, DevicePlacementType } from '../windTopology.data';
 
 export interface LayoutResult {
-  mode: LayoutMode;
   /** 节点坐标(id → {x, y}) */
   positions: Record<string, { x: number; y: number }>;
-  /** 需隐藏的节点 id(当前恒为空:所有点位以末端小点呈现) */
-  hiddenNodeIds: Set<string>;
   /** 数据布点放置方式(id → 方式) */
   devicePlacement: Record<string, DevicePlacementType>;
 }
 
-const MARGIN = 60;
+/** 第 idx 列(0=总进、1..5=lv1..lv5、6=未分级、7=总回)的 x 坐标 */
+const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
+
+/** level → 配置键:仅 1..5 视为已分级,0/NaN/越界统一映射为 'ungraded' */
+const levelKey = (lv?: number | null): number | 'ungraded' =>
+  lv !== undefined && lv !== null && Number.isFinite(lv) && lv >= 1 && lv <= 5 ? lv : 'ungraded';
+
+/** 列内纵向间距上限:按层级分别配置,未配置的层级回退 columnCapDefault */
+const capFor = (lv: number | 'ungraded') => LAYOUT.columnCap[lv as any] ?? LAYOUT.columnCapDefault;
+
+/** 列内纵向间距下限(同级时 y 轴最小间隔):按层级分别配置,未配置的层级回退 columnMinGapDefault */
+const minGapFor = (lv: number | 'ungraded') => LAYOUT.columnMinGap[lv as any] ?? LAYOUT.columnMinGapDefault;
+
+/** 各 level 连线最小长度:按层级分别配置,未配置的层级回退 minEdgeLengthDefault */
+const minEdgeFor = (lv: number | 'ungraded') => LAYOUT.minEdgeLength[lv as any] ?? LAYOUT.minEdgeLengthDefault;
+
+/** 数据布点摆放方向:按锚点层级分别配置,未配置的层级回退 devicePlacementDefault */
+const placementFor = (lv: number | 'ungraded') => LAYOUT.devicePlacement[lv as any] ?? LAYOUT.devicePlacementDefault;
+
+/** 数据布点与锚点距离:按锚点层级分别配置,未配置的层级回退 deviceOffsetDefault */
+const deviceOffsetFor = (lv: number | 'ungraded') => LAYOUT.deviceOffset[lv as any] ?? LAYOUT.deviceOffsetDefault;
 
 /**
- * 从左到右列式布局:
- *   矿井位于最左、垂直居中;L1→L2→L3→L4→L5 各占一列从左到右依次排布,未分级点位在最后一列;
- *   同层点位在列内按层级内稳定排序上下对称均匀分布(保证连线不交叉),整图不闭合(不绘制回风闭合边)。
- * 数据布点:已绑定 → 测风点位正下方(node-side);未绑定 → 图下方一行。
+ * 同级子列的水平偏移量(即该 level 连线最小长度):子列 x = 父列 x + max(sameLevelOffset, minEdgeLength)。
  */
-export function computeLayout(data: TopologyData, width: number, height: number): LayoutResult {
+const siblingGapFor = (col: TopoNodeData[]) => Math.max(LAYOUT.sameLevelOffset, minEdgeFor(levelKey(col[0]?.level)));
+
+/**
+ * 巷道线模型布局(从左到右列式):
+ *   先算 x:动态列序 总进点 → lv1..lv5 巷道点 → 未分级 → 总回点,
+ *   同级关系(parent.level === child.level)的子节点成列紧随父级列后:同一父列的子节点并入同一列
+ *   (按父序排列,连线平行等长、互不交叉),链式同级(a→b→c)逐级顺延,后续列顺延;
+ *   同级子列 x = 父列 x + max(sameLevelOffset, minEdgeLength[level])(默认 = colGap,与普通列距一致);
+ *   再按最终 x 分组算 y:每组(x 槽)按节点数量围绕 centerY 均匀分布
+ *   (间距 = max(columnMinGap[level], min(columnCap[level], 可用高度均分)));
+ *   各层列内节点顺序:提供链式结构(chains)时按 (链下标, 链内位置) 排序(链间上下并行、同链路径顺序),
+ *   未提供时回退父点位顺序 + 风量降序;
+ *   巷道连线为单条直线(父 → 子),中点标注(子节点名称 + 风量)由渲染层 edgeLabel 绘制;
+ *   数据布点:摆放方向与距离按锚点(测风点位)层级由 LAYOUT.devicePlacement / deviceOffset 决定
+ *   (默认:level<3 左侧、=3 正下方、>3 右侧,未分级/无 level 正下方),未绑定在图底一行。
+ */
+export function computeLayout(data: TopologyData, width: number, height: number, chains?: AreaChain[]): LayoutResult {
   const W = width < 100 ? LAYOUT_WIDTH : width;
   const H = height < 100 ? LAYOUT_HEIGHT : height;
 
   const positions: Record<string, { x: number; y: number }> = {};
-  const hiddenNodeIds = new Set<string>();
   const devicePlacement: Record<string, DevicePlacementType> = {};
 
-  const mine = data.nodes.find((n) => n.category === 0);
+  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 devices = data.nodes.filter((n) => n.category === 2);
 
@@ -58,7 +82,20 @@ export function computeLayout(data: TopologyData, width: number, height: number)
     }
   }
 
-  // 层级内稳定排序:L1 按风量降序,其后各层按父点位顺序分组、组内风量降序
+  // 链式排序索引(可选):提供链式结构时,各层列内节点按 (链下标, 链内位置) 排序,
+  // 链间上下并行堆叠、同链节点按路径顺序,保证连线不交叉
+  const chainRank: Record<string, { chainIdx: number; pos: number }> = {};
+  if (chains && chains.length) {
+    chains.forEach((chain, ci) => {
+      chain.points.forEach((p, pi) => {
+        const pid = `point:${p.id}`;
+        if (!chainRank[pid]) chainRank[pid] = { chainIdx: ci, pos: pi };
+      });
+    });
+  }
+
+  // 层级内稳定排序:提供链式结构时按链顺序(不在链上的点位排后、组内风量降序);
+  // 未提供时回退 L1 按风量降序,其后各层按父点位顺序分组、组内风量降序
   const levelOrder: Record<number, TopoNodeData[]> = {};
   {
     const parentIndex = new Map<string, number>();
@@ -67,6 +104,17 @@ export function computeLayout(data: TopologyData, width: number, height: number)
     for (let lv = 1; lv <= 5; lv++) {
       const list = byLevel.get(lv) || [];
       const sorted = [...list].sort((a, b) => {
+        const ca = chainRank[a.id];
+        const cb = chainRank[b.id];
+        if (chains && chains.length) {
+          if (ca && cb) {
+            if (ca.chainIdx !== cb.chainIdx) return ca.chainIdx - cb.chainIdx;
+            return ca.pos - cb.pos;
+          }
+          if (ca) return -1;
+          if (cb) return 1;
+          return (b.airVolume ?? 0) - (a.airVolume ?? 0);
+        }
         const ra = rank(parentOf[a.id]);
         const rb = rank(parentOf[b.id]);
         if (ra !== rb) return ra - rb;
@@ -80,58 +128,138 @@ export function computeLayout(data: TopologyData, width: number, height: number)
     }
   }
 
-  // 数据布点 → 绑定点(sensor 边 source=点、target=布点)
-  const boundPointOf: Record<string, string> = {};
+  // 数据布点 → 绑定巷道点(sensor 边 source=巷道点、target=布点)
+  const sensorSourceOf: Record<string, string> = {};
   for (const l of data.links) {
-    if (l.kind === 'sensor') boundPointOf[l.target] = l.source;
+    if (l.kind === 'sensor') sensorSourceOf[l.target] = l.source;
   }
 
-  const unboundCount = devices.filter((d) => !boundPointOf[d.id]).length;
+  const unboundCount = devices.filter((d) => !sensorSourceOf[d.id]).length;
   const unboundAreaH = unboundCount > 0 ? LAYOUT.unboundAreaH : 0;
   const centerY = (H - unboundAreaH) / 2;
 
-  // 列内均匀分布:以车道中心为基准上下对称展开
-  const placeColumn = (pts: TopoNodeData[], x: number, laneY: number, spread: number, cap = 90) => {
-    const n = pts.length;
-    if (!n) return;
-    const spacing = n > 1 ? Math.min(cap, spread / (n - 1)) : 0;
-    const start = laneY - (spacing * (n - 1)) / 2;
-    pts.forEach((p, i) => {
-      positions[p.id] = { x, y: start + i * spacing };
-    });
-  };
+  // —— 先算 x 轴:动态列序(固定 8 个基础槽 + 同级子节点独立列)——
+  // 基础槽:总进(0) → lv1..lv5(1..5) → 未分级(6) → 总回(7),未分级无节点时保留空槽(保持后续列位稳定)
+  const cols: TopoNodeData[][] = [];
+  if (rootIn) cols.push([rootIn]);
+  else cols.push([]);
+  for (let lv = 1; lv <= 5; lv++) {
+    cols.push(levelOrder[lv]?.length ? [...levelOrder[lv]] : []);
+  }
+  cols.push(ungraded.length ? [...ungraded] : []);
+  if (rootOut) cols.push([rootOut]);
+  else cols.push([]);
+
+  // 同级关系(parent.level === child.level,如 lv3 进风→lv3 回风):子节点成列紧随父级列之后,
+  // 同一父列的所有同级子节点并入同一列(保证连线平行等长、互不交叉);链式累积(a→b→c 同级时
+  // 每级父节点各自成列、逐级顺延),后续列顺延;多条入边取第一条
+  const pointLevelById: Record<string, number> = {};
+  for (const p of points) pointLevelById[p.id] = Number(p.level);
+  const moved = new Set<string>();
+  /** 同级子列(紧随其父列之后、由本次移动创建的列):同一父列的子节点共用一列 */
+  const siblingCols = new Set<TopoNodeData[]>();
+  for (const l of data.links) {
+    if (l.kind !== 'roadway') continue;
+    const pl = pointLevelById[l.source];
+    const cl = pointLevelById[l.target];
+    if (pl === undefined || pl !== cl) continue;
+    if (moved.has(l.target)) continue;
+    moved.add(l.target);
+    const childCol = cols.findIndex((c) => c.some((n) => n.id === l.target));
+    if (childCol < 0) continue;
+    const childNode = cols[childCol].find((n) => n.id === l.target)!;
+    const parentCol = cols.findIndex((c) => c.some((n) => n.id === l.source));
+    if (parentCol < 0) continue;
+    // 先从原列移除子节点
+    cols[childCol] = cols[childCol].filter((n) => n.id !== l.target);
+    const sibling = cols[parentCol + 1];
+    if (sibling && siblingCols.has(sibling)) {
+      // 父列已有同级子列:并入该列,同一父列的子节点共列、连线平行等长
+      sibling.push(childNode);
+    } else {
+      // 无同级子列:新建一列紧随父列之后
+      cols.splice(parentCol + 1, 0, [childNode]);
+      siblingCols.add(cols[parentCol + 1]);
+    }
+    // 原列若变空则删除(索引随插入位置移位:父列在子列之前时子列索引 +1)
+    if (cols[childCol].length === 0) {
+      const emptyIdx = parentCol < childCol ? childCol + 1 : childCol;
+      if (cols[emptyIdx] && cols[emptyIdx].length === 0) cols.splice(emptyIdx, 1);
+    }
+  }
+  // 同级子列按父节点在父列中的顺序排序:子节点镜像父节点纵向位置,连线平行等长、互不交叉
+  for (const col of cols) {
+    if (!siblingCols.has(col)) continue;
+    const parentColIdx = cols.indexOf(col) - 1;
+    const parentCol = parentColIdx >= 0 ? cols[parentColIdx] : undefined;
+    if (!parentCol) continue;
+    const parentIdxOf = new Map<string, number>();
+    parentCol.forEach((p, i) => parentIdxOf.set(p.id, i));
+    col.sort((a, b) => (parentIdxOf.get(parentOf[a.id] ?? '') ?? -1) - (parentIdxOf.get(parentOf[b.id] ?? '') ?? -1));
+  }
+
+  // 按列序赋 x:普通列取 laneX(i);同级子列取「父列 x + max(sameLevelOffset, minEdgeLength[level])」,
+  // 使各 level 连线最小长度可配置生效(默认 = colGap,与普通列距一致),收集有序节点列表(供按 x 分组算 y)
+  const ordered: TopoNodeData[] = [];
+  let prevX = -Infinity; // 前一列(父列)的 x
+  cols.forEach((col, i) => {
+    const x = siblingCols.has(col) ? prevX + siblingGapFor(col) : laneX(i);
+    prevX = x;
+    for (const n of col) {
+      positions[n.id] = { x, y: 0 };
+      ordered.push(n);
+    }
+  });
 
-  // 从左到右列式布局:矿井 → 存在层级列(1..5)→ 未分级列(不闭合)
-  // 各列内纵向间距上限按层级分别配置(columnCap),未配置的层级回退 columnCapDefault
+  // —— 再按最终 x 分组算 y:每组按节点数量围绕 centerY 均匀分布(间距 = max(下限, min(组 cap, 可用高度均分)))——
   const maxSpread = Math.max(40, Math.min(200, centerY - 40));
-  if (mine) positions[mine.id] = { x: MARGIN, y: centerY };
-  let colX = MARGIN + LAYOUT.colGap;
-  for (let lv = 1; lv <= 5; lv++) {
-    placeColumn(levelOrder[lv] || [], colX, centerY, maxSpread, LAYOUT.columnCap[lv] ?? LAYOUT.columnCapDefault);
-    colX += LAYOUT.colGap;
+  const groups = new Map<number, TopoNodeData[]>(); // x 槽 → 节点(保持有序)
+  for (const p of ordered) {
+    const x = positions[p.id].x;
+    if (!groups.has(x)) groups.set(x, []);
+    groups.get(x)!.push(p);
+  }
+  for (const [x, list] of groups) {
+    const n = list.length;
+    const lv = list[0].level !== undefined && list[0].level !== null ? Number(list[0].level) : 'ungraded';
+    const cap = capFor(lv);
+    const minGap = minGapFor(lv);
+    const spacing = n > 1 ? Math.max(minGap, Math.min(cap, maxSpread / (n - 1))) : 0;
+    const start = centerY - (spacing * (n - 1)) / 2;
+    list.forEach((p, i) => {
+      positions[p.id] = { x, y: start + i * spacing };
+    });
   }
-  if (ungraded.length) placeColumn(ungraded, colX, centerY, maxSpread, LAYOUT.columnCap.ungraded ?? LAYOUT.columnCapDefault);
 
-  // 数据布点:已绑定 → 测风点位正下方(node-side);未绑定 → 图下方一行
+  // 数据布点:摆放方向与距离按锚点(测风点位)层级由 LAYOUT.devicePlacement / deviceOffset 决定
+  // (未分级/无 level 回退 ungraded/Default),未绑定布点在图底一行
   const bottom: TopoNodeData[] = [];
   for (const d of devices) {
-    const pid = boundPointOf[d.id];
+    const pid = sensorSourceOf[d.id];
     const anchor = pid ? positions[pid] : undefined;
     if (!anchor) {
       devicePlacement[d.id] = 'bottom';
       bottom.push(d);
       continue;
     }
-    positions[d.id] = { x: anchor.x, y: anchor.y + LAYOUT.deviceOffset };
-    devicePlacement[d.id] = 'node-side';
+    const lk = levelKey(pid !== undefined ? pointLevelById[pid] : undefined);
+    const placement = placementFor(lk);
+    const off = deviceOffsetFor(lk);
+    positions[d.id] =
+      placement === 'left'
+        ? { x: anchor.x - off, y: anchor.y }
+        : placement === 'right'
+          ? { x: anchor.x + off, y: anchor.y }
+          : { x: anchor.x, y: anchor.y + off };
+    devicePlacement[d.id] = placement;
   }
   if (bottom.length) {
-    const gap = bottom.length > 1 ? Math.min(120, (W - MARGIN * 2) / (bottom.length - 1)) : 0;
-    const x0 = MARGIN + (W - MARGIN * 2 - gap * (bottom.length - 1)) / 2;
+    const gap = bottom.length > 1 ? Math.min(120, (W - LAYOUT.margin * 2) / (bottom.length - 1)) : 0;
+    const x0 = LAYOUT.margin + (W - LAYOUT.margin * 2 - gap * (bottom.length - 1)) / 2;
     bottom.forEach((d, i) => {
       positions[d.id] = { x: x0 + i * gap, y: H - LAYOUT.unboundRowGap };
     });
   }
 
-  return { mode: 'linear', positions, hiddenNodeIds, devicePlacement };
+  return { positions, devicePlacement };
 }

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

@@ -32,9 +32,7 @@
           刷新
         </a-button>
         <a-divider type="vertical" />
-        <span v-if="editMode" class="select-text"
-          >双击「数据布点」→ 单击巷道连线/末端小点或用风节点绑定/换绑;双击巷道连线/用风节点删除测风点位;双击短连线解绑</span
-        >
+        <span v-if="editMode" class="select-text">双击巷道连线:未绑定→选择数据点位绑定;已绑定→解绑</span>
       </div>
 
       <!-- 图例 -->
@@ -42,36 +40,13 @@
         <div class="legend">
           <span class="legend-item">
             <span class="legend-dot" :style="{ background: categories[0]?.color }"></span>
-            部门
+            地面
           </span>
+          <!-- 层级颜色已转移到巷道连线:图例以小横线展示连线颜色 -->
           <span v-for="lv in levelList" :key="lv.level" class="legend-item">
-            <span class="legend-dot" :style="{ background: lv.color }"></span>
+            <span class="legend-bar" :style="{ background: lv.color }"></span>
             {{ lv.text }}
           </span>
-          <!-- <span class="legend-item">
-            <span class="legend-dot" :style="{ background: roadwayColorMap.intake }"></span>
-            巷道·进风
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: roadwayColorMap.return }"></span>
-            巷道·回风
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: '#1890ff' }"></span>
-            数据布点·已绑定
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: '#bfbfbf' }"></span>
-            数据布点·未绑定
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: statusColorMap.normal }"></span>
-            数据布点·正常
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: statusColorMap.offline }"></span>
-            数据布点·离线
-          </span> -->
         </div>
       </div>
     </div>
@@ -98,15 +73,42 @@
         </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 { onMounted, onUnmounted } from 'vue';
+  import { computed, onMounted, onUnmounted, 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, statusColorMap } from './windTopology.data';
+  import { categories, levelTextMap, levelColorMap, statusColorMap, POINT_COLOR } from './windTopology.data';
   import { useTopology } from './hooks/useTopology';
+  import { getWindrectListNoUsed } from './windTopology.api';
 
   /** 图例层级项:按 levelTextMap/levelColorMap 顺序生成(矿井进风 → 矿井回风) */
   const levelList = Object.keys(levelTextMap).map((k) => {
@@ -114,9 +116,9 @@
     return { level, text: levelTextMap[level], color: levelColorMap[level] };
   });
 
-  /** 与 renderTopology 一致的节点配色:测风点位按层级色、数据布点按状态色、部门用分类色 */
+  /** 与 renderTopology 一致的节点配色:测风点位灰色、数据布点按状态色、地面用分类色 */
   function nodeColor(node: any): string {
-    if (node?.category === 1) return levelColorMap[node.level] || categories[1]?.color;
+    if (node?.category === 1) return POINT_COLOR;
     if (node?.category === 2) return statusColorMap[node.status || 'normal'] || categories[2]?.color;
     return categories[node?.category]?.color || '#333';
   }
@@ -124,6 +126,11 @@
   const {
     chartRef,
     editMode,
+    bindVisible,
+    bindPoint,
+    bindDeviceId,
+    confirmBind,
+    closeBindDialog,
     selectedNode,
     detailFields,
     selectedDeptId,
@@ -138,6 +145,29 @@
     dispose,
   } = useTopology();
 
+  /** 绑定对话框(useModal 钩子调用;destroyOnClose 每次打开重建表单刷新下拉数据) */
+  const [registerBindModal, { openModal, closeModal }] = useModal();
+
+  // hook 双击未绑定巷道连线置 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 toggleEditMode() {
     setEditMode(!editMode.value);
   }
@@ -213,6 +243,12 @@
           height: 10px;
           border-radius: 50%;
         }
+        .legend-bar {
+          display: inline-block;
+          width: 18px;
+          height: 3px;
+          border-radius: 2px;
+        }
       }
     }
   }
@@ -228,6 +264,12 @@
     height: 100%;
   }
 
+  .bind-point-name {
+    font-size: 13px;
+    font-weight: 500;
+    color: #333;
+  }
+
   .detail-panel {
     position: absolute;
     top: 12px;

+ 0 - 262
src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue.bak.txt

@@ -1,262 +0,0 @@
-<!-- eslint-disable vue/multi-word-component-names -->
-<template>
-  <div class="wind-topology">
-    <!-- 工具栏 -->
-    <div class="topo-toolbar">
-      <div class="toolbar-left">
-        <div class="mine-select">
-          <span class="select-label">煤矿:</span>
-          <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="放大">+</a-button>
-          <a-button @click="zoomOut" title="缩小">-</a-button>
-          <a-button @click="resetView" title="复位">复位</a-button>
-        </a-button-group>
-        <a-divider type="vertical" />
-        <a-button :type="editMode ? 'primary' : 'default'" danger @click="toggleEditMode">
-          {{ editMode ? '完成绑定' : '绑定模式' }}
-        </a-button>
-        <a-divider type="vertical" />
-        <a-button @click="refreshData" title="刷新">
-          <template #icon><SvgIcon name="refresh" /></template>
-          刷新
-        </a-button>
-        <a-divider type="vertical" />
-        <span v-if="editMode" class="select-text"
-          >双击「测风点位」→ 单击「矿井」或「数据布点」绑定;双击「数据布点」→ 单击「测风点位」绑定;双击连线解绑</span
-        >
-      </div>
-
-      <!-- 图例 -->
-      <div class="toolbar-right">
-        <div class="legend">
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: categories[0]?.color }"></span>
-            部门
-          </span>
-          <span v-for="lv in levelList" :key="lv.level" class="legend-item">
-            <span class="legend-dot" :style="{ background: lv.color }"></span>
-            {{ lv.text }}
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: statusColorMap.normal }"></span>
-            数据布点·正常
-          </span>
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: statusColorMap.offline }"></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>
-  </div>
-</template>
-
-<script setup lang="ts">
-  import { onMounted, onUnmounted } from 'vue';
-  import { SvgIcon } from '/@/components/Icon';
-  import MineCascader from '/@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
-  import { categories, levelTextMap, levelColorMap, statusColorMap } from './windTopology.data';
-  import { useTopology } from './hooks/useTopology';
-
-  /** 图例层级项:按 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 levelColorMap[node.level] || categories[1]?.color;
-    if (node?.category === 2) return statusColorMap[node.status || 'normal'] || categories[2]?.color;
-    return categories[node?.category]?.color || '#333';
-  }
-
-  const {
-    chartRef,
-    editMode,
-    selectedNode,
-    detailFields,
-    selectedDeptId,
-    setSelectedDept,
-    initChart,
-    loadTopology,
-    zoomIn,
-    zoomOut,
-    resetView,
-    setEditMode,
-    clearSelection,
-    dispose,
-  } = useTopology();
-
-  function toggleEditMode() {
-    setEditMode(!editMode.value);
-  }
-
-  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%;
-        }
-      }
-    }
-  }
-
-  .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>

+ 27 - 16
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.api.ts

@@ -1,46 +1,57 @@
 import { defHttp } from '/@/utils/http/axios';
 import { useMineDepartmentStore } from '/@/store/modules/mine';
-import type { MineAreaApiResponse, MineAreaNode, WindrectNode } from './windTopology.data';
+import type { MineAreaApiResponse, MineAreaNode, MineAreaRelationNode, WindrectNode } from './windTopology.data';
 
 enum Api {
-  getMineAreaList = '/workingface/mineArea/getMineAreaList',
+  getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
   getWindrectList = '/workingface/windrect/getWindrectList',
+  getWindrectListNoUsed = '/workingface/windrect/getWindrectListNoUsed',
   updateMineArea = '/workingface/mineArea/updateMineArea',
   deleteMineArea = '/workingface/mineArea/deleteMineArea',
 }
 
 /**
- * 获取测风网络拓扑数据(均为真实接口,按全局部门 getRoot 获取):
- *   getMineAreaList  -> 测风点位列表(参考:getMineAreaList?column=createTime&order=desc&pageNo=1&pageSize=100&deptId=)
- *   getWindrectList  -> 数据布点列表(参考:getWindrectList?column=createTime&order=desc&deptId=)
- * 绑定关系由 MineArea 字段派生:mineCode(所属矿 fax) / windrectId(绑定数据布点 id),不使用 MineAreaRelation 系列接口。
+ * 获取测风网络拓扑数据:
+ *   getMineAreaRelation   -> 测风点位列表 + 巷道关系列表(按矿编码 mineCode=fax 查询)
+ *   getWindrectList       -> 数据布点全量列表(按 deptId 查询,用于解析已绑定设备显示名)
+ *   getWindrectListNoUsed -> 未使用数据布点列表(按 deptId 查询,图底一行绘制)
+ * 巷道连线与布局以 MineAreaRelation 为数据源(每条 = 一条巷道,父子节点信息),
+ * 前端不再自行派生路径。
  */
 export const getTopologyData = async (params?: any): Promise<MineAreaApiResponse> => {
   const mineStore = useMineDepartmentStore();
-  // 全局获取:deptId 传当前用户部门(组织树根部门)id
+  // 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 [areaRes, windrectRes] = await Promise.allSettled([
-    defHttp.post(
-      { url: Api.getMineAreaList, params: { deptId, column: 'createTime', order: 'desc', pageNo: 1, pageSize: 100 } },
-      { joinParamsToUrl: true }
-    ),
+  const [relRes, windrectRes, windrectNoUsedRes] = 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 }),
+    defHttp.post({ url: Api.getWindrectListNoUsed, params: { deptId } }, { joinParamsToUrl: true }),
   ]);
   // 单个接口失败不影响另一个(避免整体空白);失败时记录原因便于定位
-  const areaList = areaRes.status === 'fulfilled' ? toList(areaRes.value) : (console.error('获取测风点位失败:', areaRes.reason), []);
+  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), []);
+  const windrectNoUsedList =
+    windrectNoUsedRes.status === 'fulfilled' ? toList(windrectNoUsedRes.value) : (console.error('获取未使用数据布点失败:', windrectNoUsedRes.reason), []);
   return {
-    mineAreaList: areaList as MineAreaNode[],
-    mineAreaRelationList: [],
+    mineAreaList: areaList,
+    mineAreaRelationList: relationList,
     windrectList: windrectList as WindrectNode[],
+    windrectNoUsedList: windrectNoUsedList as WindrectNode[],
   };
 };
 
 /** 编辑测风点位(绑定/解绑通过字段:mineCode=所属矿编码、windrectId=绑定数据布点 id) */
 export const updateArea = (params?: any) => defHttp.post({ url: Api.updateMineArea, params });
 
-/** 删除测风点位(ids 必填,逗号分隔/单 id;拓扑视图双击巷道连线删除点位时使用) */
+/** 查询未绑定(未使用)数据布点列表(按 deptId 查询,供巷道连线绑定弹窗选择) */
+export const getWindrectListNoUsed = (params?: any) =>
+  defHttp.post({ url: Api.getWindrectListNoUsed, params }, { joinParamsToUrl: true });
+
+/** 删除测风点位(ids 必填,逗号分隔/单 id;拓扑视图已移除双击删除点位交互,此接口暂保留待后续恢复) */
 export const deleteMineArea = (params?: any) => defHttp.post({ url: Api.deleteMineArea, params }, { joinParamsToUrl: true });

+ 0 - 46
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.api.ts.bak.txt

@@ -1,46 +0,0 @@
-import { defHttp } from '/@/utils/http/axios';
-import { useMineDepartmentStore } from '/@/store/modules/mine';
-import type { MineAreaApiResponse, MineAreaNode, WindrectNode } from './windTopology.data';
-
-enum Api {
-  getMineAreaList = '/workingface/mineArea/getMineAreaList',
-  getWindrectList = '/workingface/windrect/getWindrectList',
-  updateMineArea = '/workingface/mineArea/updateMineArea',
-}
-
-/**
- * 获取测风网络拓扑数据(均为真实接口,按全局部门 getRoot 获取):
- *   getMineAreaList  -> 测风点位列表(参考:getMineAreaList?column=createTime&order=desc&pageNo=1&pageSize=100&deptId=)
- *   getWindrectList  -> 数据布点列表(参考:getWindrectList?column=createTime&order=desc&deptId=)
- * 绑定关系由 MineArea 字段派生:mineCode(所属矿 fax) / windrectId(绑定数据布点 id),不使用 MineAreaRelation 系列接口。
- */
-export const getTopologyData = async (params?: any): Promise<MineAreaApiResponse> => {
-  const mineStore = useMineDepartmentStore();
-  // 全局获取:deptId 传当前用户部门(组织树根部门)id
-  const deptId = params?.deptId || mineStore.getRootId;
-  // 兼容两种响应结构:分页 { records } 或裸数组
-  const toList = (res: any): any[] => (Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : []);
-
-  const [areaRes, windrectRes] = await Promise.allSettled([
-    defHttp.post(
-      { url: Api.getMineAreaList, params: { deptId, column: 'createTime', order: 'desc', pageNo: 1, pageSize: 100 } },
-      { joinParamsToUrl: true }
-    ),
-    defHttp.post(
-      { url: Api.getWindrectList, params: { deptId, column: 'createTime', order: 'desc' } },
-      { joinParamsToUrl: true }
-    ),
-  ]);
-  // 单个接口失败不影响另一个(避免整体空白);失败时记录原因便于定位
-  const areaList = areaRes.status === 'fulfilled' ? toList(areaRes.value) : (console.error('获取测风点位失败:', areaRes.reason), []);
-  const windrectList =
-    windrectRes.status === 'fulfilled' ? toList(windrectRes.value) : (console.error('获取数据布点失败:', windrectRes.reason), []);
-  return {
-    mineAreaList: areaList as MineAreaNode[],
-    mineAreaRelationList: [],
-    windrectList: windrectList as WindrectNode[],
-  };
-};
-
-/** 编辑测风点位(绑定/解绑通过字段:mineCode=所属矿编码、windrectId=绑定数据布点 id) */
-export const updateArea = (params?: any) => defHttp.post({ url: Api.updateMineArea, params });

+ 236 - 315
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts

@@ -9,13 +9,17 @@ export interface CategoryDef {
   symbolSize: number; // 节点大小
 }
 
-/** 3 类节点:部门(组织树,只读)、测风点位、数据布点 */
+/** 3 类节点:地面(前端生成,只读)、测风点位、数据布点 */
 export const categories: CategoryDef[] = [
-  { name: '部门', color: '#722ed1', symbol: 'diamond', symbolSize: 26 },
+  { name: '地面', color: '#722ed1', symbol: 'diamond', symbolSize: 26 },
   { name: '测风点位', color: '#fa8c16', symbol: 'pin', symbolSize: 30 },
   { name: '数据布点', color: '#1890ff', symbol: 'roundRect', symbolSize: 18 },
 ];
 
+/** 测风点位统一节点样式:灰色小圆点(层级/类型区分转移到连线颜色) */
+export const POINT_COLOR = '#8c8c8c';
+export const POINT_SIZE = 8;
+
 // ==================== 状态颜色映射 ====================
 
 export const statusColorMap: Record<string, string> = {
@@ -35,7 +39,7 @@ export const levelTextMap: Record<number, string> = {
   5: '矿井回风',
 };
 
-/** 测风点位层级配色(5 级互不混淆,且避开部门紫 #722ed1 / 数据布点蓝 #1890ff),用于按层级区分节点颜色 */
+/** 测风点位层级配色(5 级互不混淆,且避开部门紫 #722ed1 / 数据布点蓝 #1890ff),用于按层级区分巷道连线颜色 */
 export const levelColorMap: Record<number, string> = {
   1: '#f5222d', // 矿井进风
   2: '#fa8c16', // 采区进风
@@ -46,18 +50,15 @@ export const levelColorMap: Record<number, string> = {
 
 // ==================== 通风示意图布局参数 ====================
 
-/** 巷道连线配色:进风(intake)/ 回风(return) */
-export const roadwayColorMap: Record<'intake' | 'return', string> = {
-  intake: '#1677ff',
-  return: '#fa541c',
-};
+/** 数据布点放置方式:left=锚点左侧 / down=锚点正下方 / right=锚点右侧 / bottom=图下方一行 */
+export type DevicePlacementType = 'left' | 'down' | 'right' | 'bottom';
 
-/** 拓扑布局参数(px) */
+/** 拓扑布局参数(px)——按 level 1..5 与未分级列分别配置,未配置的层级回退各 Default 值 */
 export const LAYOUT = {
+  /** 画布左边距(地面(进风侧)点起始 x,也用于未绑定布点行的两端留白) */
+  margin: 60,
   /** 列间距(从左到右各层级列之间的水平距离) */
   colGap: 300,
-  /** 已绑定数据布点绘制在测风点位正下方的垂直距离(即传感器连线长度) */
-  deviceOffset: 30,
   /** 未绑定数据布点行距图底距离 */
   unboundRowGap: 30,
   /** 存在未绑定数据布点时底部预留高度 */
@@ -69,6 +70,42 @@ export const LAYOUT = {
   columnCap: { 1: 120, 2: 120, 3: 120, 4: 120, 5: 120, ungraded: 120 } as Record<number, number> & { ungraded: number },
   /** 未在 columnCap 中配置的层级使用的间距上限(px) */
   columnCapDefault: 200,
+  /**
+   * 各列内纵向节点间距下限(px),按层级 1..5 与未分级列分别配置(同级时 y 轴最小间隔);
+   * 实际间距 = max(下限, min(上限, 可用高度均分)),默认 0 时不钳制。
+   */
+  columnMinGap: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, ungraded: 0 } as Record<number, number> & { ungraded: number },
+  /** 未在 columnMinGap 中配置的层级使用的间距下限(px) */
+  columnMinGapDefault: 0,
+  /**
+   * 各 level 连线最小长度(px),按层级 1..5 与未分级列分别配置:
+   * 同级父子关系(parent.level === child.level)时子巷道点列的水平偏移量
+   * (子列 x = 父列 x + max(sameLevelOffset, minEdgeLength)),保证短巷道线可读;
+   * 默认与 colGap 一致,布局与普通列距相同。
+   */
+  minEdgeLength: { 1: 300, 2: 300, 3: 300, 4: 300, 5: 300, ungraded: 300 } as Record<number, number> & { ungraded: number },
+  /** 未在 minEdgeLength 中配置的层级使用的连线最小长度(px) */
+  minEdgeLengthDefault: 300,
+  /** 同级父子关系的子巷道点列水平偏移下限(px):右移量取 max(sameLevelOffset, minEdgeLength),避免垂直绘制 */
+  sameLevelOffset: 200,
+  /**
+   * 数据布点摆放方向,按锚点(测风点位)层级分别配置:left=左侧 / down=正下方 / right=右侧;
+   * 未分级/无 level 锚点回退 ungraded(默认 down)。
+   */
+  devicePlacement: {
+    1: 'left',
+    2: 'left',
+    3: 'down',
+    4: 'right',
+    5: 'right',
+    ungraded: 'down',
+  } as Record<number, DevicePlacementType> & { ungraded: DevicePlacementType },
+  /** 未在 devicePlacement 中配置的层级使用的布点方向 */
+  devicePlacementDefault: 'down' as DevicePlacementType,
+  /** 数据布点与锚点(测风点位)的距离(px),按层级 1..5 与未分级列分别配置(即传感器连线长度) */
+  deviceOffset: { 1: 30, 2: 30, 3: 30, 4: 30, 5: 30, ungraded: 30 } as Record<number, number> & { ungraded: number },
+  /** 未在 deviceOffset 中配置的层级使用的布点距离(px) */
+  deviceOffsetDefault: 30,
 };
 
 /** 巷道分支风量标注文本(通风网络图风格:在巷道连线上标注测风点位风量),无风量返回空串 */
@@ -95,13 +132,21 @@ export function createGraphOption(): EChartsOption {
         // 边统一带风流方向箭头(进风→右、回风→左,由 roadway 边 source→target 决定)
         edgeSymbol: ['none', 'arrow'],
         edgeSymbolSize: [0, 10],
-        // 巷道分支风量标注(通风网络图风格:在连线上标注对应测风点位风量)
+        // 巷道连线中点标注(富文本):第一行测风点位名称、第二行风量、第三行绿色圆点 + 已绑定数据布点名称;
+        // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
         edgeLabel: {
           show: true,
           position: 'middle',
-          fontSize: 10,
-          color: '#666',
-          formatter: (p: any) => p?.data?.flowLabel || '',
+          verticalAlign: 'middle',
+          // midLabel 由渲染层按 rich 标记生成:{name|...}\n{volume|...}\n{dot|● }{device|...} 或 {name|...}\n{volume|...}\n{dotEmpty|● }
+          formatter: (p: any) => p?.data?.midLabel || '',
+          rich: {
+            name: { fontSize: 14, color: '#333', fontWeight: 'bold', lineHeight: 20 },
+            volume: { fontSize: 12, color: '#666', lineHeight: 18 },
+            dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
+            device: { fontSize: 12, color: '#333', lineHeight: 18 },
+            dotEmpty: { fontSize: 12, color: '#d9d9d9', lineHeight: 18 },
+          },
         },
         label: {
           show: true,
@@ -129,10 +174,7 @@ export interface DetailField {
 }
 
 export const nodeDetailFields: Record<string, DetailField[]> = {
-  部门: [
-    { label: '部门名称', key: 'name' },
-    { label: '是否为矿井', key: 'isLeafText' },
-  ],
+  地面: [{ label: '节点名称', key: 'name' }],
   测风点位: [
     { label: '测风点位名称', key: 'name' },
     { label: '层级', key: 'levelText' },
@@ -178,9 +220,9 @@ export interface TopoLinkData {
   source: string;
   target: string;
   label?: string;
-  /** 连线类型:org=部门内部 / mine=测风点位→矿 / sensor=测风点位→数据布点 / roadway=测风点位间巷道连线 */
-  kind?: 'org' | 'mine' | 'sensor' | 'roadway';
-  /** 巷道连线所属测风点位 id(roadway 边指向子级点位;矿井闭合边无值,不可删除/绑定) */
+  /** 连线类型:sensor=数据布点绑定 / roadway=巷道连线(父巷道 → 子巷道 直线,或地面/lv5 根边) */
+  kind?: 'sensor' | 'roadway';
+  /** 巷道连线所属测风点位 id(roadway 边指向子级点位;根边指向 lv1/lv5 巷道点),双击绑定时定位 */
   pointId?: string;
   /** 风流方向:intake=进风(左→右)/ return=回风(右→左) */
   flow?: 'intake' | 'return';
@@ -193,47 +235,6 @@ export interface TopologyData {
 
 // ==================== API 响应类型 ====================
 
-/** 组织树节点(部门拓扑,只读) */
-export interface OrgPathNode {
-  id: string;
-  departName: string;
-  parentId: string | null;
-  isLeaf: boolean;
-  /** 矿井编码(仅矿井叶子节点有值) */
-  fax?: string;
-  /** 树层级深度(根=0,子级+1),用于按层级分行布局 */
-  depth: number;
-}
-
-/**
- * 递归收集以 depart 为根的组织子树(含 depart 自身及其全部后代),作为部门层节点。
- * 数据来源:useMineDepartmentStore.getRoot(当前用户部门)。
- */
-export function collectOrgSubtree(depart?: any): OrgPathNode[] {
-  if (!depart) return [];
-  const result: OrgPathNode[] = [];
-  const seen = new Set<string>();
-  const walk = (node: any, depth: number) => {
-    if (!node) return;
-    // 去重兜底,避免组织树数据异常导致节点重复
-    if (seen.has(node.id)) return;
-    seen.add(node.id);
-    result.push({
-      id: node.id,
-      departName: node.departName,
-      parentId: node.parentId ?? null,
-      isLeaf: !!node.isLeaf,
-      fax: node.fax,
-      depth,
-    });
-    if (Array.isArray(node.childDepart)) {
-      for (const child of node.childDepart) walk(child, depth + 1);
-    }
-  };
-  walk(depart, 0);
-  return result;
-}
-
 /** API -> MineArea(测风点位,level 为通风类型层级) */
 export interface MineAreaNode {
   id: string;
@@ -260,307 +261,227 @@ export interface WindrectNode {
   [key: string]: any;
 }
 
-/** API -> getTopologyData 合并返回值(关系接口已弃用,mineAreaRelationList 恒为空) */
+/** API -> MineAreaRelation(巷道关系:一条巷道 = 父巷道 → 子巷道) */
+export interface MineAreaRelationNode {
+  id: string;
+  mineCode: string;
+  parentId: string;
+  childId: string;
+  createTime?: string;
+  updateTime?: string;
+  [key: string]: any;
+}
+
+/** 关系数组元素:一条巷道(父子节点信息),parent 为主要节点 */
+export interface AreaRelation {
+  parent: MineAreaNode;
+  child: MineAreaNode;
+}
+
+/** 链式结构:一条从父级到子级的巷道路径(points 按父子顺序排列) */
+export interface AreaChain {
+  id: string;
+  /** 链上的巷道点(按父子顺序,parent → child) */
+  points: MineAreaNode[];
+}
+
+/** 前端生成根节点 id:地面(root:in 进风侧 / root:out 回风侧) */
+export const ROOT_IN_ID = 'root:in';
+export const ROOT_OUT_ID = 'root:out';
+
+/** API -> getTopologyData 合并返回值 */
 export interface MineAreaApiResponse {
   mineAreaList: MineAreaNode[];
-  mineAreaRelationList: never[];
+  mineAreaRelationList: MineAreaRelationNode[];
   windrectList: WindrectNode[];
+  /** 未使用数据布点列表(getWindrectListNoUsed,图底一行绘制) */
+  windrectNoUsedList: WindrectNode[];
 }
 
-/** 状态渲染文本 */
-export const statusTextMap: Record<string, string> = {
-  normal: '正常',
-  abnormal: '异常',
-  offline: '离线',
-};
-
 /**
- * 从测风点位名称中提取"采区标识"候选(用于同采区巷道匹配连线):
- *   1) 数字编号优先:如 "101回风顺槽" → "101"、"3-1煤层回风" → "3-1";
- *   2) 采区/盘区词:如 "北一采区进风" → "北一采区"、"101采区回风" → "101采区"(同时含数字 "101")。
- * 返回按优先级排序、去重的候选 key 列表。
+ * 由 getMineAreaRelation 返回的 mineAreaList + mineAreaRelationList 生成关系数组。
+ * 每个元素 { parent, child } 描述一条巷道(父子节点信息),parent 为主要节点;
+ * 过滤 parent/child 引用缺失(脏数据)的关系。
  */
-export function extractDistrictKeys(name?: string): string[] {
-  const keys: string[] = [];
-  if (!name) return keys;
-  const nums = name.match(/\d+(?:-\d+)*/g);
-  if (nums) {
-    for (const n of nums) if (!keys.includes(n)) keys.push(n);
+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 area = name.match(/([\u4e00-\u9fa5A-Za-z0-9]+)(?:采区|盘区|区段)/);
-  if (area && !keys.includes(area[0])) keys.push(area[0]);
-  return keys;
+  const result: AreaRelation[] = [];
+  for (const r of relations) {
+    if (!r.parentId || !r.childId) continue;
+    const parent = byId.get(String(r.parentId));
+    const child = byId.get(String(r.childId));
+    if (parent && child) result.push({ parent, child });
+  }
+  return result;
 }
 
 /**
- * 前端智能路径计算(不依赖关系接口,均为真实数据派生):
- *   1) 层级跳级补齐:取实际存在的层级链(1~5 升序),相邻存在层级之间连线,
- *      缺失层级自动跨级相连(如无 L4 时 L3 直接连 L5),保证每个测风点位都有完整回路路径;
- *   2) 名称分组匹配:父子连线优先按"采区标识"(extractDistrictKeys)同标识匹配,
- *      未匹配的子点退回数量均分兜底,保证每个子点恰有一条入边;
- *   3) 矿井起止边:矿井 → 首个存在层级(进风起点)、末个存在层级 → 矿井(回风闭合边,无 pointId);
- *   4) 未分级点位:按名称含"回风/进风"关键字判定所属侧,从对应侧(回风→末层级 / 进风→首层级 / 中段→L3)扇出。
- * flow:目标层级 ≤3 为进风(intake),>3 为回风(return)。
+ * 由巷道关系数组(每条 = 一条巷道 父 → 子)构建链式结构:
+ * 从「从未作为子节点的根点位」(一般即 lv1 进风点)出发,沿 parent→child 深度遍历;
+ * 遇分支(一个父节点多个子节点)时主链走第一个子节点,其余子节点各自另起新链(共享前缀只保留在第一条链),
+ * 保证链间上下并行、互不交叉;孤立点位(存在于 mineAreas 但不在任何关系中)各自成为单节点链追加在最后;
+ * 链顺序 = 根点链在前(按 level 升序、风量降序稳定排序)→ 分支链 → 孤立点链,
+ * visited 去重防止环路/重复引用,纯环路数据(无根可达)的点位兜底为单节点链,保证不丢点。
  */
-export function buildVentilationPaths(points: MineAreaNode[], mineOrgId: string): TopoLinkData[] {
-  const prefix = (id: any) => (id === undefined || id === null || id === '' ? '' : `point:${id}`);
-  const byLevel = new Map<number, MineAreaNode[]>();
-  const ungraded: MineAreaNode[] = [];
-  for (const p of points) {
-    if (!p.id) continue;
-    const lv = Number(p.level);
-    if (Number.isFinite(lv) && lv >= 1 && lv <= 5) {
-      if (!byLevel.has(lv)) byLevel.set(lv, []);
-      byLevel.get(lv)!.push(p);
-    } else {
-      ungraded.push(p);
-    }
-  }
-  const links: TopoLinkData[] = [];
-  if (!mineOrgId) return links;
-
-  // 存在的层级链(升序)
-  const presentLevels: number[] = [];
-  for (let lv = 1; lv <= 5; lv++) if (byLevel.get(lv)?.length) presentLevels.push(lv);
-
-  // 相邻存在层级之间连线(跳级补齐):进风侧发散(fan-out),回风侧汇聚(fan-in)——保证每个回风顺槽
-  // 都各自绘制一条到回风巷道的连线(而非仅按子级数量生成边数)
-  for (let i = 0; i < presentLevels.length - 1; i++) {
-    const parentLevel = presentLevels[i];
-    const childLevel = presentLevels[i + 1];
-    const isReturn = childLevel > 3;
-    assignLevelLinks(
-      byLevel.get(parentLevel)!,
-      byLevel.get(childLevel)!,
-      isReturn ? 'return' : 'intake',
-      prefix,
-      links,
-      isReturn ? 'fan-in' : 'fan-out'
-    );
+export function buildChains(relations: AreaRelation[], mineAreas?: MineAreaNode[]): AreaChain[] {
+  const byId = new Map<string, MineAreaNode>();
+  const childMap = new Map<string, string[]>();
+  const isChild = new Set<string>();
+  for (const r of relations) {
+    if (!r.parent || !r.child || !r.parent.id || !r.child.id) continue;
+    const pid = String(r.parent.id);
+    const cid = String(r.child.id);
+    byId.set(pid, r.parent);
+    byId.set(cid, r.child);
+    if (!childMap.has(pid)) childMap.set(pid, []);
+    const list = childMap.get(pid)!;
+    if (!list.includes(cid)) list.push(cid);
+    isChild.add(cid);
   }
 
-  // 矿井起边:矿井 → 首个存在层级;闭合边:末个存在层级 → 矿井(无 pointId,不可删除)
-  if (presentLevels.length) {
-    const first = presentLevels[0];
-    const firstFlow: 'intake' | 'return' = first > 3 ? 'return' : 'intake';
-    for (const p of byLevel.get(first)!) {
-      const pid = prefix(p.id);
-      links.push({ source: mineOrgId, target: pid, kind: 'roadway', flow: firstFlow, pointId: pid });
-    }
-    const last = presentLevels[presentLevels.length - 1];
-    const lastFlow: 'intake' | 'return' = last > 3 ? 'return' : 'intake';
-    for (const p of byLevel.get(last)!) {
-      links.push({ source: prefix(p.id), target: mineOrgId, kind: 'roadway', flow: lastFlow });
+  const chains: AreaChain[] = [];
+  const used = new Set<string>(); // 已被某条链占用的点位 id(共享前缀只保留在第一条链)
+  const branchRoots: string[] = []; // 分支起点(父链完成后作为新链根处理)
+
+  const walk = (startId: string) => {
+    const points: MineAreaNode[] = [];
+    const seen = new Set<string>(); // 防环路
+    let cur: string | undefined = startId;
+    while (cur !== undefined && byId.has(cur) && !seen.has(cur)) {
+      seen.add(cur);
+      if (used.has(cur)) break; // 该点已被前置链占用(共享前缀)
+      used.add(cur);
+      points.push(byId.get(cur)!);
+      const children = (childMap.get(cur) || []).filter((c) => !used.has(c) && byId.has(c));
+      if (children.length === 0) break;
+      cur = children[0];
+      // 分支:其余子节点各自另起新链(统一在父链之后处理,保证父链在前)
+      for (const branch of children.slice(1)) {
+        if (!used.has(branch)) branchRoots.push(branch);
+      }
     }
-  }
-
-  // 未分级点位:按名称关键字判定进风/回风侧,就近从对应侧扇出
-  if (ungraded.length) {
-    const sideOf = (n: string): 'return' | 'intake' | 'middle' => (n.includes('回风') ? 'return' : n.includes('进风') ? 'intake' : 'middle');
-    const lastHubs = presentLevels.length ? byLevel.get(presentLevels[presentLevels.length - 1])! : [];
-    const firstHubs = byLevel.get(1) || (presentLevels.length ? byLevel.get(presentLevels[0])! : []);
-    const middleHubs = byLevel.get(3) || firstHubs;
-    const bySide: Record<'return' | 'intake' | 'middle', MineAreaNode[]> = { return: [], intake: [], middle: [] };
-    for (const u of ungraded) bySide[sideOf(u.name || '')].push(u);
-    if (bySide.return.length && lastHubs.length) assignLevelLinks(lastHubs, bySide.return, 'return', prefix, links);
-    if (bySide.intake.length && firstHubs.length) assignLevelLinks(firstHubs, bySide.intake, 'intake', prefix, links);
-    if (bySide.middle.length && middleHubs.length) assignLevelLinks(middleHubs, bySide.middle, 'intake', prefix, links);
-  }
-
-  return links;
-}
-
-/**
- * 父子连线分配(两种模式):
- *   fan-out(进风侧发散):每个子级点位连一条入边,优先连到"采区标识"匹配的父点,
- *       命中多个父点时连到当前负载最小者;未匹配的子点均分给负载最小的父点。
- *   fan-in(回风侧汇聚):每个父级点位连一条出边到回风巷道——多个父点(如回风顺槽 L3)
- *       各自连入同一个子级回风巷道(L4),保证每个点位都有连线而非只按子级数量生成;
- *       同样优先采区标识匹配,未匹配走负载最小兜底。
- * pointId:fan-out 指向子级点位(入边所属),fan-in 指向源(父级)点位(出边所属),
- * 供双击删除/绑定时定位该连线代表的测风点位。
- */
-function assignLevelLinks(
-  parents: MineAreaNode[],
-  children: MineAreaNode[],
-  flow: 'intake' | 'return',
-  prefix: (id: any) => string,
-  links: TopoLinkData[],
-  mode: 'fan-out' | 'fan-in' = 'fan-out'
-) {
-  if (!parents.length || !children.length) return;
-  const sortBy = (a: MineAreaNode, b: MineAreaNode) => (b.airVolume ?? 0) - (a.airVolume ?? 0) || String(a.id).localeCompare(String(b.id));
-  const sortedParents = [...parents].sort(sortBy);
-  const sortedChildren = [...children].sort(sortBy);
-  const parentKeys = sortedParents.map((p) => extractDistrictKeys(p.name));
-  const childKeys = sortedChildren.map((c) => extractDistrictKeys(c.name));
-  const parentLoad = new Array(sortedParents.length).fill(0);
-  const childLoad = new Array(sortedChildren.length).fill(0);
-  const allParentIdx = sortedParents.map((_, i) => i);
-  const allChildIdx = sortedChildren.map((_, i) => i);
-  const pickLeast = (load: number[], idxs: number[]): number => {
-    let best = idxs[0];
-    for (const i of idxs) if (load[i] < load[best]) best = i;
-    return best;
-  };
-  const emit = (parentIdx: number, childIdx: number) => {
-    parentLoad[parentIdx]++;
-    childLoad[childIdx]++;
-    const source = prefix(sortedParents[parentIdx].id);
-    const target = prefix(sortedChildren[childIdx].id);
-    links.push({
-      source,
-      target,
-      kind: 'roadway',
-      flow,
-      pointId: mode === 'fan-in' ? source : target,
-    });
+    if (points.length) chains.push({ id: `chain:${chains.length}`, points });
   };
 
-  if (mode === 'fan-in') {
-    // 回风侧:每个父级点位连一条出边到回风巷道(汇聚)
-    const unmatched: number[] = [];
-    for (let pi = 0; pi < sortedParents.length; pi++) {
-      let matched = -1;
-      for (const key of parentKeys[pi]) {
-        const idxs: number[] = [];
-        childKeys.forEach((cKeys, ci) => {
-          if (cKeys.includes(key)) idxs.push(ci);
-        });
-        if (idxs.length) {
-          matched = pickLeast(childLoad, idxs);
-          break;
-        }
-      }
-      if (matched >= 0) emit(pi, matched);
-      else unmatched.push(pi);
+  // 根点:从未作为子节点的点位;按 level 升序、风量降序稳定排序,保证链顺序确定
+  const roots = [...byId.keys()]
+    .filter((id) => !isChild.has(id))
+    .sort((a, b) => {
+      const la = Number(byId.get(a)?.level) || 0;
+      const lb = Number(byId.get(b)?.level) || 0;
+      if (la !== lb) return la - lb;
+      return (Number(byId.get(b)?.airVolume) || 0) - (Number(byId.get(a)?.airVolume) || 0);
+    });
+  for (const r of roots) {
+    if (!used.has(r)) walk(r);
+  }
+  // 分支链(父链之后、孤立点之前)
+  let qi = 0;
+  while (qi < branchRoots.length) {
+    const start = branchRoots[qi++];
+    if (!used.has(start)) walk(start);
+  }
+  // 孤立点位(存在于 mineAreas 但不在任何关系中)各自成为单节点链,追加在最后
+  if (mineAreas) {
+    const known = new Set(byId.keys());
+    for (const a of mineAreas) {
+      if (!a.id) continue;
+      const id = String(a.id);
+      if (known.has(id)) continue;
+      known.add(id);
+      chains.push({ id: `chain:${chains.length}`, points: [a] });
     }
-    for (const pi of unmatched) emit(pi, pickLeast(childLoad, allChildIdx));
-  } else {
-    // 进风侧:每个子级点位连一条入边(发散)
-    const unmatched: number[] = [];
-    for (let ci = 0; ci < sortedChildren.length; ci++) {
-      let matched = -1;
-      for (const key of childKeys[ci]) {
-        const idxs: number[] = [];
-        parentKeys.forEach((pKeys, pi) => {
-          if (pKeys.includes(key)) idxs.push(pi);
-        });
-        if (idxs.length) {
-          matched = pickLeast(parentLoad, idxs);
-          break;
-        }
-      }
-      if (matched >= 0) emit(matched, ci);
-      else unmatched.push(ci);
+  }
+  // 未被任何链覆盖的关系点位(如纯环路数据:每个节点都是子节点、无根可达)各自成为单节点链,
+  // 保证布局仍能覆盖全部点位(visited 去重 + 此处兜底共同防止环路导致节点丢失)
+  for (const id of byId.keys()) {
+    if (!used.has(id)) {
+      used.add(id);
+      chains.push({ id: `chain:${chains.length}`, points: [byId.get(id)!] });
     }
-    for (const ci of unmatched) emit(pickLeast(parentLoad, allParentIdx), ci);
   }
+
+  return chains;
 }
 
 /**
- * 将 API 响应与组织子树转换为 TopologyData(不使用 MineAreaRelation 关系表)
- * 三类节点:部门(orgNodes,只读)、测风点位(getMineAreaList 全部记录,与测风点位管理列表一致)、数据布点(Windrect)
- * 三类边(按字段派生):
- *   1) 部门内部(org parent → child)
- *   2) 巷道连线(roadway):buildVentilationPaths 前端智能路径计算(层级跳级补齐 + 名称分组 + 均分兜底 + 未分级侧判定)
- *   3) 测风点位 → 数据布点(area.windrectId 匹配数据布点 id)
- * 节点 id 按分类前缀(org:/point:/device:)保证全局唯一,过滤缺失 id 并按原始 id 去重,
- * 避免 ECharts graph 因重复/缺失 id 报错;rawId 保留原始实体 id 供接口调用。
+ * 以巷道关系数组为数据源生成 TopologyData(每条巷道 = 父 → 子 单条直线连线):
+ * 节点:
+ *   1) 地面点(root:in 进风侧 / root:out 回风侧),前端生成;
+ *   2) 巷道点(mineAreas 全部记录,按 level 分组,未分级单独归列);
+ *   3) 数据布点(仅未使用列表 windrectNoUsed 生成,图底一行绘制;
+ *      已绑定设备的节点不再绘制,其信息由渲染层写入所属巷道连线 edgeLabel)。
+ * 边:
+ *   1) 地面 → lv1 巷道点(roadway 进风起点);
+ *   2) 每条巷道 parent → child(roadway 单条直线,流向按子节点 level ≤3 进风 / >3 回风);
+ *   3) lv5 巷道点 → 地面(roadway 回风终点)。
+ * 节点 id 前缀(point:/device:)保证全局唯一,过滤缺失 id 并去重。
  */
-export function transformToTopologyData(orgNodes: OrgPathNode[], mineAreas: MineAreaNode[], windrects: WindrectNode[]): TopologyData {
+export function transformToTopologyData(mineAreas: MineAreaNode[], relations: AreaRelation[], windrectNoUsed: WindrectNode[]): TopologyData {
   const prefix = (cat: string, id: any) => (id === undefined || id === null || id === '' ? '' : `${cat}:${id}`);
 
-  // 部门节点:过滤缺失 id + 去重
-  const orgById = new Map<string, OrgPathNode>();
-  for (const o of orgNodes) {
-    if (!o.id) continue;
-    const pid = prefix('org', o.id);
-    if (!orgById.has(pid)) orgById.set(pid, o);
-  }
-  const faxToOrgId = new Map<string, string>();
-  for (const o of orgById.values()) {
-    if (o.fax) faxToOrgId.set(o.fax, prefix('org', o.id));
-  }
-
-  // 测风点位:getMineAreaList 返回的全部记录(与测风点位管理列表一致),过滤缺失 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 pointRawToId = new Map<string, string>();
-  for (const [pid, a] of pointById) pointRawToId.set(a.id, pid);
 
-  // 数据布点:过滤缺失 id + 去重
+  // 数据布点(仅未使用列表):过滤缺失 id + 去重;无 sensor 连线 → 布局自动排到图底一行
   const deviceById = new Map<string, WindrectNode>();
-  for (const d of windrects) {
+  for (const d of windrectNoUsed) {
     if (!d.id) continue;
     const pid = prefix('device', d.id);
     if (!deviceById.has(pid)) deviceById.set(pid, d);
   }
-  const deviceRawToId = new Map<string, string>();
-  for (const [pid, d] of deviceById) deviceRawToId.set(d.id, pid);
 
   const links: TopoLinkData[] = [];
-  // 测风点位已绑定数据布点数(一对一约束用)
-  const areaSensorCount: Record<string, number> = {};
-
-  // 1) 部门内部边
-  for (const o of orgById.values()) {
-    const pid = prefix('org', o.id);
-    if (o.parentId && orgById.has(prefix('org', o.parentId))) {
-      links.push({ source: prefix('org', o.parentId), target: pid, kind: 'org' });
-    }
+  // 巷道边:每条关系一条 父 → 子 直线(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 flow: 'intake' | 'return' = Number(rel.child.level) <= 3 ? 'intake' : 'return';
+    links.push({ source: parentId, target: childId, kind: 'roadway', flow, pointId: childId });
   }
-  // 2) 巷道连线(roadway):由下方第 4 步生成(buildVentilationPaths 前端智能路径计算)
-  // 3) 测风点位 → 数据布点
+
+  // 地面 → lv1 巷道点(进风起点)
   for (const [pid, a] of pointById) {
-    const deviceId = a.windrectId ? deviceRawToId.get(a.windrectId) : undefined;
-    if (deviceId) {
-      links.push({ source: pid, target: deviceId, kind: 'sensor' });
-      areaSensorCount[pid] = (areaSensorCount[pid] || 0) + 1;
+    if (Number(a.level) === 1) {
+      links.push({ source: ROOT_IN_ID, target: pid, kind: 'roadway', flow: 'intake', pointId: pid });
     }
   }
-  // 4) 巷道连线(roadway):前端智能路径计算(层级跳级补齐 + 名称分组 + 均分兜底 + 未分级侧判定
-  //    起止边(矿井→首个存在层级、末个存在层级→矿井闭合边)与未分级点位连线均在该函数内生成
-  const mineOrgId = Array.from(orgById.keys())[0] || '';
-  if (mineOrgId) {
-    for (const l of buildVentilationPaths(mineAreas, mineOrgId)) links.push(l);
+  // lv5 巷道点 → 地面(回风终点
+  for (const [pid, a] of pointById) {
+    if (Number(a.level) === 5) {
+      links.push({ source: pid, target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: pid });
+    }
   }
 
   const nodes: TopoNodeData[] = [
-    // 部门节点(只读)
-    ...Array.from(orgById.entries()).map(([pid, o]) => ({
+    // 地面(前端生成,只读)
+    { id: ROOT_IN_ID, name: '地面', category: 0 },
+    { id: ROOT_OUT_ID, name: '地面', category: 0 },
+    // 巷道点
+    ...Array.from(pointById.entries()).map(([pid, a]) => ({
       id: pid,
-      rawId: o.id,
-      name: o.departName,
-      category: 0,
-      parentId: o.parentId,
-      isLeaf: o.isLeaf,
-      isLeafText: o.isLeaf ? '是' : '否',
-      fax: o.fax,
-      depth: o.depth ?? 0,
+      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] || '',
     })),
-    // 测风点位节点
-    ...Array.from(pointById.entries()).map(([pid, a]) => {
-      const orgId = a.mineCode ? faxToOrgId.get(a.mineCode) : undefined;
-      return {
-        id: pid,
-        rawId: a.id,
-        name: a.name,
-        category: 1,
-        mineCode: a.mineCode,
-        mineName: orgId ? orgById.get(orgId)?.departName || a.mineCode : '',
-        airVolume: a.airVolume,
-        windrectId: a.windrectId,
-        childCount: areaSensorCount[pid] || 0,
-        level: a.level,
-        levelText: levelTextMap[a.level] || '',
-      };
-    }),
-    // 数据布点节点
+    // 数据布点
     ...Array.from(deviceById.entries()).map(([pid, d]) => ({
       id: pid,
       rawId: d.id,

+ 0 - 364
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts.bak.txt

@@ -1,364 +0,0 @@
-import type { EChartsOption } from 'echarts';
-
-// ==================== 节点分类定义 ====================
-
-export interface CategoryDef {
-  name: string; // 分类名称
-  color: string; // 节点颜色
-  symbol: string; // ECharts 图形:circle / rect / diamond / roundRect / pin
-  symbolSize: number; // 节点大小
-}
-
-/** 3 类节点:部门(组织树,只读)、测风点位、数据布点 */
-export const categories: CategoryDef[] = [
-  { name: '部门', color: '#722ed1', symbol: 'diamond', symbolSize: 26 },
-  { name: '测风点位', color: '#fa8c16', symbol: 'pin', symbolSize: 30 },
-  { name: '数据布点', color: '#1890ff', symbol: 'roundRect', symbolSize: 26 },
-];
-
-// ==================== 状态颜色映射 ====================
-
-export const statusColorMap: Record<string, string> = {
-  normal: '#52c41a', // 正常
-  abnormal: '#fa8c16', // 异常
-  offline: '#999999', // 离线
-};
-
-// ==================== 测风点位层级映射 ====================
-
-/** 测风点位通风类型层级(level 字段语义),用于按层级分行绘制拓扑 */
-export const levelTextMap: Record<number, string> = {
-  1: '矿井进风',
-  2: '采区进风',
-  3: '采区用风',
-  4: '采区回风',
-  5: '矿井回风',
-};
-
-/** 测风点位层级配色(5 级互不混淆,且避开部门紫 #722ed1 / 数据布点蓝 #1890ff),用于按层级区分节点颜色 */
-export const levelColorMap: Record<number, string> = {
-  1: '#f5222d', // 矿井进风
-  2: '#fa8c16', // 采区进风
-  3: '#fadb14', // 采区用风
-  4: '#13c2c2', // 采区回风
-  5: '#eb2f96', // 矿井回风
-};
-
-// ==================== 三层布局参数 ====================
-
-/** 每类节点用于排序的权重字段(值越大越靠左):测风点位按已绑定数据布点数排序 */
-export const weightKey: Record<number, string> = {
-  1: 'childCount',
-};
-
-/** 节点横向间距基数(px),实际 = basePadding / 该层节点数 */
-export const basePadding = 800;
-
-/** 画布宽高占位(chart 100% 容器,此处设参考值) */
-export const LAYOUT_WIDTH = 1200;
-export const LAYOUT_HEIGHT = 700;
-
-// ==================== ECharts graph 基础配置 ====================
-
-export function createGraphOption(): EChartsOption {
-  return {
-    tooltip: { trigger: 'item', formatter: '{b}' },
-    series: [
-      {
-        type: 'graph',
-        layout: 'none', // 固定位置,由 applyHierarchicalLayout 分配坐标
-        roam: true,
-        draggable: true, // 始终可拖,编辑模式只影响 roam
-        categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
-        edgeSymbol: ['none', 'none'],
-        edgeSymbolSize: [0, 10],
-        label: {
-          show: true,
-          position: 'bottom',
-          fontSize: 11,
-          formatter: (p: any) => p.name || '',
-        },
-        lineStyle: { color: 'source', curveness: 0, width: 2, opacity: 0.7 },
-        emphasis: {
-          focus: 'adjacency',
-          lineStyle: { width: 3, opacity: 1 },
-        },
-        nodes: [],
-        links: [],
-      },
-    ],
-  };
-}
-
-// ==================== 节点详情字段 ====================
-
-export interface DetailField {
-  label: string;
-  key: string;
-}
-
-export const nodeDetailFields: Record<string, DetailField[]> = {
-  部门: [
-    { label: '部门名称', key: 'name' },
-    { label: '是否为矿井', key: 'isLeafText' },
-  ],
-  测风点位: [
-    { label: '测风点位名称', key: 'name' },
-    { label: '层级', key: 'levelText' },
-    { label: '所属矿井', key: 'mineName' },
-    { label: '矿编码', key: 'mineCode' },
-    { label: '风量(m³/min)', key: 'airVolume' },
-    { label: '已绑定数据布点数', key: 'childCount' },
-  ],
-  数据布点: [
-    { label: '设备编码', key: 'deviceCode' },
-    { label: '设备位置', key: 'devicePos' },
-    { label: '矿井名称', key: 'mineName' },
-    { label: '状态', key: 'statusText' },
-  ],
-};
-
-// ==================== 拓扑数据类型 ====================
-
-export interface TopoNodeData {
-  id: string;
-  /** 原始实体 id(MineArea/Windrect/部门 id),绑定/解绑调 updateMineArea 时使用 */
-  rawId?: string;
-  name: string;
-  category: number; // categories 索引
-  parentId?: string | null;
-  isLeaf?: boolean;
-  isLeafText?: string;
-  /** 矿井编码(仅部门叶子节点(矿点)有值,测风点位→矿井绑定时取矿编码) */
-  fax?: string;
-  /** 测风点位通风类型层级(1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风),用于按层级分行 */
-  level?: number;
-  /** 层级显示名(levelTextMap 映射) */
-  levelText?: string;
-  status?: string; // normal / abnormal / offline
-  airVolume?: number;
-  childCount?: number;
-  mineCode?: string;
-  deviceCode?: string;
-  devicePos?: string;
-  mineName?: string;
-  [key: string]: any;
-}
-
-export interface TopoLinkData {
-  source: string;
-  target: string;
-  label?: string;
-  /** 连线类型:org=部门内部 / mine=测风点位→矿 / sensor=测风点位→数据布点 */
-  kind?: 'org' | 'mine' | 'sensor';
-}
-
-export interface TopologyData {
-  nodes: TopoNodeData[];
-  links: TopoLinkData[];
-}
-
-// ==================== API 响应类型 ====================
-
-/** 组织树节点(部门拓扑,只读) */
-export interface OrgPathNode {
-  id: string;
-  departName: string;
-  parentId: string | null;
-  isLeaf: boolean;
-  /** 矿井编码(仅矿井叶子节点有值) */
-  fax?: string;
-  /** 树层级深度(根=0,子级+1),用于按层级分行布局 */
-  depth: number;
-}
-
-/**
- * 递归收集以 depart 为根的组织子树(含 depart 自身及其全部后代),作为部门层节点。
- * 数据来源:useMineDepartmentStore.getRoot(当前用户部门)。
- */
-export function collectOrgSubtree(depart?: any): OrgPathNode[] {
-  if (!depart) return [];
-  const result: OrgPathNode[] = [];
-  const seen = new Set<string>();
-  const walk = (node: any, depth: number) => {
-    if (!node) return;
-    // 去重兜底,避免组织树数据异常导致节点重复
-    if (seen.has(node.id)) return;
-    seen.add(node.id);
-    result.push({
-      id: node.id,
-      departName: node.departName,
-      parentId: node.parentId ?? null,
-      isLeaf: !!node.isLeaf,
-      fax: node.fax,
-      depth,
-    });
-    if (Array.isArray(node.childDepart)) {
-      for (const child of node.childDepart) walk(child, depth + 1);
-    }
-  };
-  walk(depart, 0);
-  return result;
-}
-
-/** API -> MineArea(测风点位,level 为通风类型层级) */
-export interface MineAreaNode {
-  id: string;
-  mineCode: string;
-  name: string;
-  level: number; // 1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风
-  airVolume: number;
-  regulationId?: string;
-  windrectId?: string;
-  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 -> getTopologyData 合并返回值(关系接口已弃用,mineAreaRelationList 恒为空) */
-export interface MineAreaApiResponse {
-  mineAreaList: MineAreaNode[];
-  mineAreaRelationList: never[];
-  windrectList: WindrectNode[];
-}
-
-/** 状态渲染文本 */
-export const statusTextMap: Record<string, string> = {
-  normal: '正常',
-  abnormal: '异常',
-  offline: '离线',
-};
-
-/**
- * 将 API 响应与组织子树转换为 TopologyData(不使用 MineAreaRelation 关系表)
- * 三类节点:部门(orgNodes,只读)、测风点位(getMineAreaList 全部记录,与测风点位管理列表一致)、数据布点(Windrect)
- * 三类边(按字段派生):
- *   1) 部门内部(org parent → child)
- *   2) 测风点位 → 矿(area.mineCode 匹配部门 fax)
- *   3) 测风点位 → 数据布点(area.windrectId 匹配数据布点 id)
- * 节点 id 按分类前缀(org:/point:/device:)保证全局唯一,过滤缺失 id 并按原始 id 去重,
- * 避免 ECharts graph 因重复/缺失 id 报错;rawId 保留原始实体 id 供接口调用。
- */
-export function transformToTopologyData(
-  orgNodes: OrgPathNode[],
-  mineAreas: MineAreaNode[],
-  windrects: WindrectNode[]
-): TopologyData {
-  const prefix = (cat: string, id: any) => (id === undefined || id === null || id === '' ? '' : `${cat}:${id}`);
-
-  // 部门节点:过滤缺失 id + 去重
-  const orgById = new Map<string, OrgPathNode>();
-  for (const o of orgNodes) {
-    if (!o.id) continue;
-    const pid = prefix('org', o.id);
-    if (!orgById.has(pid)) orgById.set(pid, o);
-  }
-  const faxToOrgId = new Map<string, string>();
-  for (const o of orgById.values()) {
-    if (o.fax) faxToOrgId.set(o.fax, prefix('org', o.id));
-  }
-
-  // 测风点位:getMineAreaList 返回的全部记录(与测风点位管理列表一致),过滤缺失 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 pointRawToId = new Map<string, string>();
-  for (const [pid, a] of pointById) pointRawToId.set(a.id, pid);
-
-  // 数据布点:过滤缺失 id + 去重
-  const deviceById = new Map<string, WindrectNode>();
-  for (const d of windrects) {
-    if (!d.id) continue;
-    const pid = prefix('device', d.id);
-    if (!deviceById.has(pid)) deviceById.set(pid, d);
-  }
-  const deviceRawToId = new Map<string, string>();
-  for (const [pid, d] of deviceById) deviceRawToId.set(d.id, pid);
-
-  const links: TopoLinkData[] = [];
-  // 测风点位已绑定数据布点数(一对一约束用)
-  const areaSensorCount: Record<string, number> = {};
-
-  // 1) 部门内部边
-  for (const o of orgById.values()) {
-    const pid = prefix('org', o.id);
-    if (o.parentId && orgById.has(prefix('org', o.parentId))) {
-      links.push({ source: prefix('org', o.parentId), target: pid, kind: 'org' });
-    }
-  }
-  // 2) 测风点位 → 矿
-  for (const [pid, a] of pointById) {
-    const orgId = a.mineCode ? faxToOrgId.get(a.mineCode) : undefined;
-    if (orgId) links.push({ source: pid, target: orgId, kind: 'mine' });
-  }
-  // 3) 测风点位 → 数据布点
-  for (const [pid, a] of pointById) {
-    const deviceId = a.windrectId ? deviceRawToId.get(a.windrectId) : undefined;
-    if (deviceId) {
-      links.push({ source: pid, target: deviceId, kind: 'sensor' });
-      areaSensorCount[pid] = (areaSensorCount[pid] || 0) + 1;
-    }
-  }
-
-  const nodes: TopoNodeData[] = [
-    // 部门节点(只读)
-    ...Array.from(orgById.entries()).map(([pid, o]) => ({
-      id: pid,
-      rawId: o.id,
-      name: o.departName,
-      category: 0,
-      parentId: o.parentId,
-      isLeaf: o.isLeaf,
-      isLeafText: o.isLeaf ? '是' : '否',
-      fax: o.fax,
-      depth: o.depth ?? 0,
-    })),
-    // 测风点位节点
-    ...Array.from(pointById.entries()).map(([pid, a]) => {
-      const orgId = a.mineCode ? faxToOrgId.get(a.mineCode) : undefined;
-      return {
-        id: pid,
-        rawId: a.id,
-        name: a.name,
-        category: 1,
-        mineCode: a.mineCode,
-        mineName: orgId ? orgById.get(orgId)?.departName || a.mineCode : '',
-        airVolume: a.airVolume,
-        windrectId: a.windrectId,
-        childCount: areaSensorCount[pid] || 0,
-        level: a.level,
-        levelText: levelTextMap[a.level] || '',
-      };
-    }),
-    // 数据布点节点
-    ...Array.from(deviceById.entries()).map(([pid, d]) => ({
-      id: pid,
-      rawId: d.id,
-      name: d.devicePos || d.deviceCode || d.id,
-      category: 2,
-      deviceCode: d.deviceCode,
-      devicePos: d.devicePos,
-      mineName: d.mineName,
-      mineCode: d.mineCode,
-      status: String(d.status) === '1' ? 'normal' : 'offline',
-      statusText: String(d.status) === '1' ? '正常' : '停用',
-    })),
-  ];
-
-  return { nodes, links };
-}

+ 20 - 2
src/views/monitor/sensorMonitor/index.vue

@@ -61,7 +61,7 @@
   import { useListPage } from '/@/hooks/system/useListPage';
   import { advancedRoutePush } from '/@/utils';
   import { useMineDepartmentStore } from '/@/store/modules/mine';
-  import { boardData, columns, historyColumns, schemas, historySchemas } from './sensorMonitor.data';
+  import { boardData, columns, historyColumns, historySchemas, schemas } from './sensorMonitor.data';
   import { getWindrectData, getWindrectHistory } from './sensorMonitor.api';
   import { getCurrentStatistics } from '/@/api/workingface';
   import { useInitForm } from './hooks/form';
@@ -151,7 +151,25 @@
         autoSearch: true,
         labelWidth: 120,
         model: { deptId: hiscode },
-        schemas: historySchemas,
+        schemas: [
+          {
+            field: 'deptId',
+            label: '煤矿名称',
+            component: 'MineCascader',
+            colProps: { span: 6 },
+            required: true,
+            // 切换煤矿时清空已选测风装置,避免提交其他矿的设备
+            componentProps: ({ formActionType }) => ({
+              initFromStore: false,
+              rootId: departId,
+              onChange: () => {
+                formActionType.setFieldsValue({ windrectId: undefined });
+              },
+            }),
+          },
+          ...schemas,
+          ...historySchemas,
+        ],
         showAdvancedButton: false,
         schemaGroupNames: ['常规查询'],
       },

+ 28 - 20
src/views/monitor/sensorMonitor/sensorMonitor.data.ts

@@ -123,16 +123,38 @@ export const schemas: FormSchema[] = [
   //     params: {},
   //   },
   // },
-  {
-    label: '煤矿状态',
-    field: 'gjMineStatus',
-    component: 'Input',
-    colProps: { span: 6 },
-  },
+  // {
+  //   label: '煤矿状态',
+  //   field: 'gjMineStatus',
+  //   component: 'Input',
+  //   colProps: { span: 6 },
+  // },
 ];
 
 /** 历史数据查询表单(getWindrectHistory:startTime/endTime/windrectId 必填) */
 export const historySchemas: FormSchema[] = [
+  {
+    label: '测风装置',
+    field: 'windrectId',
+    component: 'ApiSelect',
+    required: true,
+    colProps: { span: 6 },
+    // 与「煤矿名称」联动:按选中矿(MineCascader 值为部门 id)过滤本矿测风装置;
+    // ApiSelect 深度监听 params 变化,切矿后自动重新请求
+    componentProps: ({ formModel, formActionType }) => ({
+      api: getWindrectList,
+      labelField: 'devicePos',
+      valueField: 'id',
+      placeholder: '请选择测风装置',
+      params: formModel?.deptId ? { deptId: formModel.deptId } : {},
+      // 筛选(挂载加载/切矿重查)后默认选中第一项,无需手动选择
+      onOptionsChange: (options) => {
+        if (options && options.length) {
+          formActionType.setFieldsValue({ windrectId: options[0].value });
+        }
+      },
+    }),
+  },
   {
     field: 'startTime',
     label: '开始时间',
@@ -159,18 +181,4 @@ export const historySchemas: FormSchema[] = [
     defaultValue: dayjs().format('YYYY-MM-DD 23:59:59'),
     colProps: { span: 6 },
   },
-  {
-    label: '测风装置',
-    field: 'windrectId',
-    component: 'ApiSelect',
-    required: true,
-    colProps: { span: 6 },
-    componentProps: {
-      api: getWindrectList,
-      labelField: 'devicePos',
-      valueField: 'id',
-      placeholder: '请选择测风装置',
-      params: {},
-    },
-  },
 ];

+ 16 - 2
src/views/monitor/surfaceMonitor/index.vue

@@ -44,7 +44,7 @@
   import { Tabs, TabPane } from 'ant-design-vue';
   import MiniBoard from '/@/components/Configurable/detail/MiniBoard.vue';
   import { useListPage } from '/@/hooks/system/useListPage';
-  import { boardData, columns, historyColumns, schemas, historySchemas } from './surfaceMonitor.data';
+  import { boardData, columns, historyColumns, historySchemas, schemas } from './surfaceMonitor.data';
   import { getMineData } from './surfaceMonitor.api';
   import { getCurrentStatistics } from '/@/api/workingface';
   import { useInitForm } from './hooks/form';
@@ -127,7 +127,21 @@
       formConfig: {
         autoSearch: true,
         labelWidth: 120,
-        schemas: historySchemas,
+        schemas: [
+          {
+            field: 'deptId',
+            label: '煤矿名称',
+            component: 'MineCascader',
+            colProps: { span: 6 },
+            required: true,
+            componentProps: {
+              initFromStore: false,
+              rootId: departId,
+            },
+          },
+          ...schemas,
+          ...historySchemas,
+        ],
         showAdvancedButton: false,
         schemaGroupNames: ['常规查询'],
       },

+ 29 - 10
src/views/monitor/surfaceMonitor/surfaceMonitor.data.ts

@@ -1,6 +1,7 @@
 import type { BasicColumn, FormSchema } from '/@/components/Table';
 import { h } from 'vue';
 import { StatusColorEnum } from '/@/enums/jeecgEnum';
+import dayjs from 'dayjs';
 
 /** 顶部统计卡片(暂为占位,接口就绪后接入) */
 export const boardData = [
@@ -159,15 +160,6 @@ export const historyColumns: BasicColumn[] = [
 
 /** 实时监测查询表单(getMineData:deptId 必填) */
 export const schemas: FormSchema[] = [
-  {
-    label: '矿井名称',
-    field: 'mineName',
-    component: 'Input',
-    colProps: { span: 6 },
-    componentProps: {
-      placeholder: '请输入矿井名称',
-    },
-  },
   {
     label: '接入状态',
     field: 'accessStatus',
@@ -209,4 +201,31 @@ export const schemas: FormSchema[] = [
 ];
 
 /** 历史数据查询表单(暂无接口,占位) */
-export const historySchemas: FormSchema[] = [];
+export const historySchemas: FormSchema[] = [
+  {
+    field: 'startTime',
+    label: '开始时间',
+    component: 'DatePicker',
+    required: true,
+    componentProps: {
+      showTime: true,
+      format: 'YYYY-MM-DD HH:mm:ss',
+      valueFormat: 'YYYY-MM-DD HH:mm:ss',
+    },
+    defaultValue: dayjs().add(-30, 'day').format('YYYY-MM-DD 00:00:00'),
+    colProps: { span: 6 },
+  },
+  {
+    field: 'endTime',
+    label: '结束时间',
+    component: 'DatePicker',
+    required: true,
+    componentProps: {
+      showTime: true,
+      format: 'YYYY-MM-DD HH:mm:ss',
+      valueFormat: 'YYYY-MM-DD HH:mm:ss',
+    },
+    defaultValue: dayjs().format('YYYY-MM-DD 23:59:59'),
+    colProps: { span: 6 },
+  },
+];

+ 2 - 1
src/views/system/cadFile/app.ts

@@ -104,7 +104,7 @@ export function useMap2D(): UseMap2DReturn {
     map.attach(svc, prj);
     map.fitMapBounds();
     await map.onLoad();
-
+    console.log('debug map init');
     return map;
   }
 
@@ -318,6 +318,7 @@ export function useMap2D(): UseMap2DReturn {
   }
 
   async function bindPosition(props?: any): Promise<any | null> {
+    console.log('debug map init', map);
     if (!map) {
       message.error('地图未初始化完成,请先加载地图!');
       return null;

+ 2 - 2
src/views/system/cadFile/components/MapView.vue

@@ -3,8 +3,8 @@
     <div ref="map3dContainer"></div>
     <!-- 操作按钮面板 -->
     <div class="map-operation-panel">
-      <button :loading="loading" @click="drawerVisible = true" class="btn">隐蔽工作面列表</button>
-      <button :loading="loading" @click="emit('close')" class="btn ml-5px">返回列表</button>
+      <a-button :loading="loading" @click="drawerVisible = true" class="">测风点位列表</a-button>
+      <a-button @click="emit('close')" class="ml-5px">返回列表</a-button>
     </div>
 
     <SideDrawer v-model:open="drawerVisible" :mineCode="mineCode" :getContainer="() => map3dContainer" />

+ 15 - 3
src/views/system/cadFile/components/SideDrawer.vue

@@ -14,7 +14,7 @@
   >
     <!-- 抽屉头部:标题 + 关闭按钮 -->
     <template #title>
-      隐蔽工作面管理
+      测风点位管理
       <!-- <span class="custom-drawer-title"></span> -->
     </template>
     <template #extra>
@@ -72,6 +72,7 @@
   import { ref, computed, watch } from 'vue';
   import { Drawer, message } from 'ant-design-vue';
   import { BasicColumn, BasicTable } from '/@/components/Table';
+  import { useMineDepartmentStore } from '/@/store/modules/mine';
   import { bindPosition, removePosition, getMarkerStatusList, renderGoafMarkers } from '../app';
   import { getMineAreaList } from '../cad.api';
   import { CloseOutlined } from '@ant-design/icons-vue';
@@ -157,10 +158,21 @@
       return;
     }
 
+    // selectedMineId 为矿编码(fax,来自地图管理列表行);getMineAreaList 需按部门 id(deptId)查询,
+    // 与 MapEditModal 一致:优先按 id 直查(兼容已是 deptId 的情况),再按 fax 匹配换算
+    const mineStore = useMineDepartmentStore();
+    const mine =
+      mineStore.findDepartById(selectedMineId.value, mineStore.getDepartTree) ||
+      mineStore.findDepart((n) => n.fax === selectedMineId.value, mineStore.getDepartTree);
+    if (!mine) {
+      message.warning('未找到该矿对应的部门,请检查煤矿选择');
+      return;
+    }
+
     try {
-      // 调用接口(MineArea 列表)
+      // 调用接口(MineArea 列表,按部门 id 查询
       const res = await getMineAreaList({
-        mineCode: selectedMineId.value,
+        deptId: mine.id,
       });
       goafList.value = res || [];
       // pagination.value.current = 1; // 搜索后重置页码到第一页

+ 391 - 261
tests/useTopologyLayout.spec.ts

@@ -1,9 +1,11 @@
 import {
-  buildVentilationPaths,
-  extractDistrictKeys,
-  formatAirVolumeLabel,
+  buildRelationArray,
+  buildChains,
   transformToTopologyData,
+  formatAirVolumeLabel,
   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';
@@ -18,10 +20,20 @@ const area = (id: string, level: number, airVolume = 100, extra: Record<string,
   ...extra,
 });
 
-/** 构造一个完整的通风回路拓扑(单行示意图数据) */
-function schematicTopo(): TopologyData {
+/** 构造巷道关系(MineAreaRelation) */
+const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
+  id,
+  mineCode: 'M1',
+  parentId,
+  childId,
+  ...extra,
+});
+
+/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系与 2 个布点,供布局测试) */
+function laneTopo(): TopologyData {
   const nodes = [
-    { id: 'org:m1', name: '某矿', category: 0 },
+    { id: ROOT_IN_ID, name: '地面', category: 0 },
+    { id: ROOT_OUT_ID, name: '地面', category: 0 },
     { id: 'point:a1', name: '矿井进风1', category: 1, level: 1, airVolume: 100 },
     { id: 'point:b1', name: '采区进风1', category: 1, level: 2, airVolume: 90 },
     { id: 'point:b2', name: '采区进风2', category: 1, level: 2, airVolume: 80 },
@@ -32,189 +44,123 @@ function schematicTopo(): TopologyData {
     { id: 'device:w2', name: '传感器2', category: 2, status: 'offline' },
   ];
   const links = [
-    { source: 'org:m1', target: 'point:a1', kind: 'roadway', flow: 'intake', pointId: 'point:a1' },
+    { source: ROOT_IN_ID, target: 'point:a1', kind: 'roadway', flow: 'intake', pointId: 'point:a1' },
     { source: 'point:a1', target: 'point:b1', kind: 'roadway', flow: 'intake', pointId: 'point:b1' },
     { source: 'point:a1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' },
     { source: 'point:b1', target: 'point:c1', kind: 'roadway', flow: 'intake', pointId: 'point:c1' },
     { source: 'point:c1', target: 'point:d1', kind: 'roadway', flow: 'return', pointId: 'point:d1' },
     { source: 'point:d1', target: 'point:e1', kind: 'roadway', flow: 'return', pointId: 'point:e1' },
-    { source: 'point:e1', target: 'org:m1', kind: 'roadway', flow: 'return' },
+    { source: 'point:e1', target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: 'point:e1' },
+    // w1 绑定 b1(巷道 a1→b1 的子节点)→ sensor 源为 point:b1,布点画在 a1-b1 连线中点
     { source: 'point:b1', target: 'device:w1', kind: 'sensor' },
   ];
   return { nodes, links } as unknown as TopologyData;
 }
 
-describe('extractDistrictKeys 采区标识提取', () => {
-  test('数字编号优先', () => {
-    expect(extractDistrictKeys('101回风顺槽')).toContain('101');
-    expect(extractDistrictKeys('3-1煤层回风')).toContain('3-1');
-  });
-
-  test('采区/盘区词', () => {
-    expect(extractDistrictKeys('北一采区进风')).toContain('北一采区');
-    expect(extractDistrictKeys('101采区回风')).toContain('101采区');
-  });
-
-  test('无标识返回空', () => {
-    expect(extractDistrictKeys('总回风')).toEqual([]);
-    expect(extractDistrictKeys('')).toEqual([]);
+describe('buildRelationArray 关系数组生成', () => {
+  test('过滤 parent/child 引用缺失的脏数据', () => {
+    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].parent.id).toBe('1');
+    expect(arr[0].child.id).toBe('2');
   });
 });
 
-describe('buildVentilationPaths 前端智能路径计算', () => {
-  test('层级跳级补齐:无 L4 时 L3(回风顺槽)直接连 L5,回路闭合', () => {
-    const points = [
-      area('a1', 1, 100, { name: '主井进风' }),
-      area('b1', 2, 90, { name: '一采区进风' }),
-      area('c1', 3, 80, { name: '101回风顺槽' }),
-      area('e1', 5, 70, { name: '总回风' }),
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    // 跨级边 L3→L5(回风)
-    expect(links.some((l) => l.kind === 'roadway' && l.source === 'point:c1' && l.target === 'point:e1' && l.flow === 'return')).toBe(true);
-    // 矿井起边 → L1、闭合边 L5 → 矿井(无 pointId)
-    expect(links.some((l) => l.kind === 'roadway' && l.source === 'org:m1' && l.target === 'point:a1' && l.flow === 'intake')).toBe(true);
-    expect(links.some((l) => l.kind === 'roadway' && l.source === 'point:e1' && l.target === 'org:m1' && l.flow === 'return' && !l.pointId)).toBe(
-      true
-    );
-    // 每个点位都有完整回路路径:c1 有入边 b1→c1、出边 c1→e1
-    expect(links.some((l) => l.source === 'point:b1' && l.target === 'point:c1')).toBe(true);
-  });
-
-  test('名称分组:同采区标识相连(101 顺槽→101 回风,102 顺槽→102 回风)', () => {
-    const points = [
-      area('a1', 1, 100, { name: '主井进风' }),
-      area('b1', 2, 90, { name: '一采区进风' }),
-      area('c1', 3, 80, { name: '101回风顺槽' }),
-      area('c2', 3, 70, { name: '102回风顺槽' }),
-      area('d1', 4, 60, { name: '101采区回风' }),
-      area('d2', 4, 50, { name: '102采区回风' }),
-      area('e1', 5, 40, { name: '总回风' }),
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    // L3→L4 按采区标识匹配,而非数量均分
-    expect(links.filter((l) => l.kind === 'roadway' && l.source === 'point:c1').map((l) => l.target)).toEqual(['point:d1']);
-    expect(links.filter((l) => l.kind === 'roadway' && l.source === 'point:c2').map((l) => l.target)).toEqual(['point:d2']);
+describe('buildChains 链式结构构建', () => {
+  const chainPoint = (id: string, level: number, airVolume = 100) => ({
+    id,
+    name: id,
+    level,
+    airVolume,
+    mineCode: 'M1',
   });
+  const chainRel = (parent: any, child: any) => ({ parent, child });
 
-  test('均分兜底:无采区标识的子点每个恰一条入边且父点负载均衡', () => {
-    const points = [
-      area('a1', 1, 100, { name: '主井进风' }),
-      area('a2', 1, 90, { name: '副井进风' }),
-      area('b1', 2, 80, { name: '一号顺槽' }),
-      area('b2', 2, 70, { name: '二号顺槽' }),
-      area('b3', 2, 60, { name: '三号顺槽' }),
-      area('b4', 2, 50, { name: '四号顺槽' }),
-      area('b5', 2, 40, { name: '五号顺槽' }),
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    const l12 = links.filter((l) => l.kind === 'roadway' && l.target.startsWith('point:b'));
-    for (const id of ['b1', 'b2', 'b3', 'b4', 'b5']) {
-      expect(l12.filter((l) => l.target === `point:${id}`)).toHaveLength(1);
-    }
-    // 2 父 5 子 → 3/2 均分
-    const loads = ['a1', 'a2'].map((p) => l12.filter((l) => l.source === `point:${p}`).length);
-    expect(loads.reduce((a, b) => a + b, 0)).toBe(5);
-    expect(Math.max(...loads) - Math.min(...loads)).toBeLessThanOrEqual(1);
+  test('分支拆成独立链:共享前缀只保留在第一条链,父链在前、分支链在后', () => {
+    const a1 = chainPoint('a1', 1, 100);
+    const b1 = chainPoint('b1', 2, 60);
+    const b2 = chainPoint('b2', 2, 90);
+    const c1 = chainPoint('c1', 3, 40);
+    const c2 = chainPoint('c2', 3, 80);
+    const d1 = chainPoint('d1', 4, 30);
+    const e1 = chainPoint('e1', 5, 20);
+    const chains = buildChains([
+      chainRel(a1, b1),
+      chainRel(a1, b2),
+      chainRel(b1, c1),
+      chainRel(b2, c2),
+      chainRel(c1, d1),
+      chainRel(d1, e1),
+    ]);
+    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
+      ['a1', 'b1', 'c1', 'd1', 'e1'],
+      ['b2', 'c2'],
+    ]);
   });
 
-  test('起止边取首个/末个存在层级:无 L1 时矿井→L2,无 L5 时 L4→矿井', () => {
-    const points = [
-      area('b1', 2, 90, { name: '一采区进风' }),
-      area('c1', 3, 80, { name: '101回风顺槽' }),
-      area('d1', 4, 70, { name: '101采区回风' }),
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    expect(links.some((l) => l.source === 'org:m1' && l.target === 'point:b1' && l.flow === 'intake')).toBe(true);
-    expect(links.some((l) => l.source === 'point:d1' && l.target === 'org:m1' && l.flow === 'return' && !l.pointId)).toBe(true);
+  test('孤立点位(不在任何关系中)各自成为单节点链追加在最后', () => {
+    const a1 = chainPoint('a1', 1, 100);
+    const b1 = chainPoint('b1', 2, 90);
+    const solo = chainPoint('solo', 3, 10);
+    const chains = buildChains([chainRel(a1, b1)], [a1, b1, solo]);
+    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
+      ['a1', 'b1'],
+      ['solo'],
+    ]);
   });
 
-  test('未分级点位按名称关键字分侧:回风侧从末层级扇出,进风侧从首层级扇出', () => {
-    const points = [
-      area('a1', 1, 100, { name: '主井进风' }),
-      area('c1', 3, 80, { name: '101回风顺槽' }),
-      area('e1', 5, 70, { name: '总回风' }),
-      area('u1', 0, 60, { name: '北翼回风大巷' }), // 未分级,含"回风"
-      area('u2', 0, 50, { name: '主斜井进风' }), // 未分级,含"进风"
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    expect(links.some((l) => l.source === 'point:e1' && l.target === 'point:u1' && l.flow === 'return')).toBe(true);
-    expect(links.some((l) => l.source === 'point:a1' && l.target === 'point:u2' && l.flow === 'intake')).toBe(true);
+  test('多根点按 level 升序、风量降序稳定排序,各自成链;脏关系不报错', () => {
+    const a1 = chainPoint('a1', 1, 100);
+    const a2 = chainPoint('a2', 1, 90);
+    const a3 = chainPoint('a3', 2, 200);
+    const b1 = chainPoint('b1', 2, 80);
+    const b2 = chainPoint('b2', 2, 70);
+    const c3 = chainPoint('c3', 3, 60);
+    const chains = buildChains([
+      chainRel(a1, b1),
+      chainRel(a2, b2),
+      chainRel(a3, c3),
+      chainRel(a2, undefined), // 脏关系:child 缺失,被过滤
+    ]);
+    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
+      ['a1', 'b1'],
+      ['a2', 'b2'],
+      ['a3', 'c3'],
+    ]);
   });
 
-  test('回风侧 fan-in:5 个 L3(回风顺槽)对 1 个 L4 → 恰 5 条连线,pointId 指向源点位', () => {
-    const points = [
-      area('a1', 1, 100, { name: '主井进风' }),
-      area('b1', 2, 90, { name: '一采区进风' }),
-      area('c1', 3, 80, { name: '一号回风顺槽' }),
-      area('c2', 3, 70, { name: '二号回风顺槽' }),
-      area('c3', 3, 60, { name: '三号回风顺槽' }),
-      area('c4', 3, 50, { name: '四号回风顺槽' }),
-      area('c5', 3, 40, { name: '五号回风顺槽' }),
-      area('d1', 4, 30, { name: '一采区回风' }),
-      area('e1', 5, 20, { name: '总回风' }),
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    const l3l4 = links.filter((l) => l.kind === 'roadway' && l.target === 'point:d1' && l.source.startsWith('point:c'));
-    expect(l3l4).toHaveLength(5);
-    for (const id of ['c1', 'c2', 'c3', 'c4', 'c5']) {
-      const edges = links.filter((l) => l.source === `point:${id}` && l.target === 'point:d1' && l.flow === 'return');
-      expect(edges).toHaveLength(1);
-      expect(edges[0].pointId).toBe(`point:${id}`);
-    }
-  });
-
-  test('L4→L5 同样 fan-in:3 个采区回风各连一条到矿井回风', () => {
-    const points = [
-      area('a1', 1, 100, { name: '主井进风' }),
-      area('b1', 2, 90, { name: '一采区进风' }),
-      area('c1', 3, 80, { name: '101回风顺槽' }),
-      area('d1', 4, 70, { name: '101采区回风' }),
-      area('d2', 4, 60, { name: '102采区回风' }),
-      area('d3', 4, 50, { name: '103采区回风' }),
-      area('e1', 5, 40, { name: '总回风' }),
-    ];
-    const links = buildVentilationPaths(points, 'org:m1');
-    const l45 = links.filter((l) => l.kind === 'roadway' && l.target === 'point:e1' && l.source.startsWith('point:d'));
-    expect(l45).toHaveLength(3);
-    for (const id of ['d1', 'd2', 'd3']) {
-      expect(links.some((l) => l.source === `point:${id}` && l.target === 'point:e1' && l.pointId === `point:${id}`)).toBe(true);
-    }
+  test('纯环路数据(无根可达)兜底为单节点链,不丢点', () => {
+    const a = chainPoint('a', 1, 100);
+    const b = chainPoint('b', 2, 90);
+    const chains = buildChains([chainRel(a, b), chainRel(b, a)]);
+    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([['a'], ['b']]);
   });
 });
 
-describe('transformToTopologyData 巷道边', () => {
-  test('生成矿井起止边/闭合边/传感器边/未分级扇出,且不再生成 mine 边', () => {
-    const org = [{ id: 'org1', departName: '某矿', parentId: null, isLeaf: true, fax: 'M1', depth: 0 }];
-    const areas = [
-      area('a1', 1),
-      area('a2', 1),
-      area('b1', 2, 100, { windrectId: 'w1' }),
-      area('b2', 2),
-      area('c1', 3),
-      area('d1', 4),
-      area('e1', 5),
-      area('u1', 0), // 未分级
-    ];
-    const windrects = [{ id: 'w1', mineCode: 'M1', deviceCode: 'DC1', devicePos: '主井口', status: 1 }];
-    const data = transformToTopologyData(org, areas, windrects);
-
-    expect(data.links.some((l) => l.kind === 'mine')).toBe(false);
-    // 矿井 → L1 进风起边
-    const startEdges = data.links.filter((l) => l.kind === 'roadway' && l.source === 'org:org1' && l.flow === 'intake');
-    expect(startEdges.map((l) => l.target).sort()).toEqual(['point:a1', 'point:a2']);
-    expect(startEdges.every((l) => l.pointId === l.target)).toBe(true);
-    // L5 → 矿井回风闭合边(无 pointId)
-    const closeEdges = data.links.filter((l) => l.kind === 'roadway' && l.target === 'org:org1' && l.flow === 'return');
-    expect(closeEdges.map((l) => l.source)).toEqual(['point:e1']);
-    expect(closeEdges.every((l) => !l.pointId)).toBe(true);
-    // 传感器边(布点绑定)
-    expect(data.links.some((l) => l.kind === 'sensor' && l.source === 'point:b1' && l.target === 'device:w1')).toBe(true);
-    // 未分级点位从 L3 扇出
-    expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:c1' && l.target === 'point:u1' && l.pointId === 'point:u1')).toBe(true);
-    // 节点去重与前缀
-    expect(data.nodes.filter((n) => n.id === 'point:a1')).toHaveLength(1);
-    expect(data.nodes.some((n) => n.id === 'org:org1')).toBe(true);
+describe('transformToTopologyData 巷道线模型', () => {
+  test('生成地面/巷道点;每条巷道一条 parent→child 直线边;地面→lv1、lv5→地面', () => {
+    const areas = [area('a1', 1), area('b1', 2, 90, { windrectId: 'w1' }), area('c1', 3), area('d1', 4), area('e1', 5)];
+    const relations = [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')];
+    const windrects = [{ id: 'w1', mineCode: 'M1', devicePos: '主井口', status: 1 }];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, relations), windrects);
+
+    // 地面节点
+    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);
+    // 无中间标记/锚点节点;每条巷道一条 parent → child 直线边(pointId 指向子巷道点)
+    expect(data.nodes.filter((n) => n.category === 3)).toHaveLength(0);
+    expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:a1' && l.target === 'point:b1' && l.pointId === 'point:b1')).toBe(true);
+    // 地面 → lv1、lv5 → 地面
+    expect(data.links.some((l) => l.kind === 'roadway' && l.source === ROOT_IN_ID && l.target === 'point:a1')).toBe(true);
+    expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:e1' && l.target === ROOT_OUT_ID)).toBe(true);
+    // sensor 源为子巷道点(b1)
+    const sensor = data.links.find((l) => l.kind === 'sensor' && l.target === 'device:w1');
+    expect(sensor?.source).toBe('point:b1');
+    // 巷道流向:子节点 level ≤3 进风,>3 回风
+    expect(data.links.find((l) => l.source === 'point:c1' && l.target === 'point:d1')?.flow).toBe('return');
+    expect(data.links.find((l) => l.source === 'point:a1' && l.target === 'point:b1')?.flow).toBe('intake');
   });
 });
 
@@ -227,134 +173,318 @@ describe('formatAirVolumeLabel 风量标注', () => {
   });
 });
 
-describe('computeLayout 从左到右列式(线性)布局', () => {
-  // 列式布局几何参数:schematicTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
+describe('computeLayout 巷道线模型布局', () => {
+  // 几何参数:laneTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
   const centerY = (700 - 90) / 2; // 305
   const MARGIN = 60;
   const colGap = 300;
+  const laneX = (idx: number) => MARGIN + colGap * idx;
 
-  test('列式坐标:矿井最左,L1→L2→L3→L4→L5 各列从左到右 x 递增,同层同列', () => {
-    const layout = computeLayout(schematicTopo(), 1200, 700);
-    expect(layout.mode).toBe('linear');
+  test('列序:地面 → lv1..lv5 → 地面,x 依次递增,同层同列', () => {
+    const layout = computeLayout(laneTopo(), 1200, 700);
     const pos = layout.positions;
-    // 矿井最左、垂直居中
-    expect(pos['org:m1'].x).toBe(MARGIN);
-    expect(pos['org:m1'].y).toBe(centerY);
-    // 各层级列 x 依次递增(列距 colGap)
-    expect(pos['point:a1'].x).toBe(MARGIN + colGap);
-    expect(pos['point:b1'].x).toBe(pos['point:a1'].x + colGap);
-    expect(pos['point:c1'].x).toBe(pos['point:b1'].x + colGap);
-    expect(pos['point:d1'].x).toBe(pos['point:c1'].x + colGap);
-    expect(pos['point:e1'].x).toBe(pos['point:d1'].x + colGap);
-    // 同层点位同列
-    expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
+    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
+    expect(pos['point:a1'].x).toBe(laneX(1));
+    expect(pos['point:b1'].x).toBe(laneX(2));
+    expect(pos['point:c1'].x).toBe(laneX(3));
+    expect(pos['point:d1'].x).toBe(laneX(4));
+    expect(pos['point:e1'].x).toBe(laneX(5));
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(7));
+    expect(pos['point:b2'].x).toBe(pos['point:b1'].x); // 同层同列
+    // 地面节点垂直居中
+    expect(pos[ROOT_IN_ID].y).toBe(centerY);
+    expect(pos[ROOT_OUT_ID].y).toBe(centerY);
   });
 
-  test('同层点位列内上下对称等距:间距 = min(该层级配置上限, 可用高度均分),以 centerY 居中', () => {
-    const layout = computeLayout(schematicTopo(), 1200, 700);
+  test('数据布点:方向按锚点层级(level<3 左侧 / =3 正下方 / >3 右侧),未绑定 → 图下方', () => {
+    const layout = computeLayout(laneTopo(), 1200, 700);
     const pos = layout.positions;
-    const cap = LAYOUT.columnCap[2] ?? LAYOUT.columnCapDefault;
-    const expected = Math.min(cap, 200); // maxSpread = min(200, centerY-40) = 200
-    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);
+    // w1 绑定 b1(lv2 < 3)→ 点位左侧
+    expect(layout.devicePlacement['device:w1']).toBe('left');
+    expect(pos['device:w1'].x).toBe(pos['point:b1'].x - LAYOUT.deviceOffset[2]);
+    expect(pos['device:w1'].y).toBe(pos['point:b1'].y);
+    // w2 未绑定 → bottom(图底一行:y = H - unboundRowGap)
+    expect(layout.devicePlacement['device:w2']).toBe('bottom');
+    expect(pos['device:w2'].y).toBe(700 - 30);
   });
 
-  test('末端小点:所有点位可见(hiddenNodeIds 为空),无隐藏锚点', () => {
-    const layout = computeLayout(schematicTopo(), 1200, 700);
-    expect(layout.hiddenNodeIds.size).toBe(0);
+  test('数据布点:绑定 lv1 锚点的布点位于点位左侧(level<3 向左)', () => {
+    const data = laneTopo();
+    data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
+    data.links.push({ source: 'point:a1', target: 'device:w3', kind: 'sensor' });
+    const layout = computeLayout(data, 1200, 700);
+    const pos = layout.positions;
+    expect(layout.devicePlacement['device:w3']).toBe('left');
+    expect(pos['device:w3'].x).toBe(pos['point:a1'].x - LAYOUT.deviceOffset[1]);
+    expect(pos['device:w3'].y).toBe(pos['point:a1'].y);
   });
 
-  test('数据布点定位:已绑定 → 点位正下方(node-side),未绑定 → 图下方', () => {
-    const layout = computeLayout(schematicTopo(), 1200, 700);
+  test('数据布点:level=3 在锚点正下方、level>3 在锚点右侧、未分级/level=0 回退正下方', () => {
+    const data = laneTopo();
+    // c1(lv3)、d1(lv4)、e1(lv5) 各绑一个布点,另加未分级与 level=0 点位绑定布点
+    data.nodes.push(
+      { id: 'device:w3', name: '传感器3', category: 2, status: 'normal' },
+      { id: 'device:w4', name: '传感器4', category: 2, status: 'normal' },
+      { id: 'device:w5', name: '传感器5', category: 2, status: 'normal' },
+      { id: 'point:u1', name: '未分级', category: 1 },
+      { id: 'device:w6', name: '传感器6', category: 2, status: 'normal' },
+      { id: 'point:u0', name: '零级', category: 1, level: 0 },
+      { id: 'device:w7', name: '传感器7', category: 2, status: 'normal' }
+    );
+    data.links.push(
+      { source: 'point:c1', target: 'device:w3', kind: 'sensor' },
+      { source: 'point:d1', target: 'device:w4', kind: 'sensor' },
+      { source: 'point:e1', target: 'device:w5', kind: 'sensor' },
+      { source: 'point:u1', target: 'device:w6', kind: 'sensor' },
+      { source: 'point:u0', target: 'device:w7', kind: 'sensor' }
+    );
+    const layout = computeLayout(data, 1200, 700);
     const pos = layout.positions;
-    // w1 绑定 b1 → node-side:x 相同、y 向下偏移 LAYOUT.deviceOffset
-    expect(layout.devicePlacement['device:w1']).toBe('node-side');
-    expect(pos['device:w1'].x).toBe(pos['point:b1'].x);
-    expect(pos['device:w1'].y).toBe(pos['point:b1'].y + LAYOUT.deviceOffset);
-    // w2 未绑定 → bottom(图底一行:y = H - unboundRowGap)
-    expect(layout.devicePlacement['device:w2']).toBe('bottom');
-    expect(pos['device:w2'].y).toBe(700 - 30);
-    expect(pos['device:w2'].y).toBeGreaterThan(pos['point:e1'].y);
+    // lv3 → 正下方
+    expect(layout.devicePlacement['device:w3']).toBe('down');
+    expect(pos['device:w3'].x).toBe(pos['point:c1'].x);
+    expect(pos['device:w3'].y).toBe(pos['point:c1'].y + LAYOUT.deviceOffset[3]);
+    // lv4 / lv5 → 右侧
+    expect(layout.devicePlacement['device:w4']).toBe('right');
+    expect(pos['device:w4'].x).toBe(pos['point:d1'].x + LAYOUT.deviceOffset[4]);
+    expect(pos['device:w4'].y).toBe(pos['point:d1'].y);
+    expect(layout.devicePlacement['device:w5']).toBe('right');
+    expect(pos['device:w5'].x).toBe(pos['point:e1'].x + LAYOUT.deviceOffset[5]);
+    expect(pos['device:w5'].y).toBe(pos['point:e1'].y);
+    // 未分级(无 level)→ 回退正下方
+    expect(layout.devicePlacement['device:w6']).toBe('down');
+    expect(pos['device:w6'].x).toBe(pos['point:u1'].x);
+    expect(pos['device:w6'].y).toBe(pos['point:u1'].y + LAYOUT.deviceOffset.ungraded);
+    // level=0(不在 1..5 范围内)→ 同样回退正下方
+    expect(layout.devicePlacement['device:w7']).toBe('down');
+    expect(pos['device:w7'].x).toBe(pos['point:u0'].x);
+    expect(pos['device:w7'].y).toBe(pos['point:u0'].y + LAYOUT.deviceOffset.ungraded);
   });
 
-  test('未分级点位绘制在最后一列(L5 右侧一列)', () => {
-    const data = schematicTopo();
-    data.nodes.push({ id: 'point:u1', name: '未分级', category: 1 });
-    data.links.push({ source: 'point:c1', target: 'point:u1', kind: 'roadway', flow: 'intake', pointId: 'point:u1' });
+  test('同级父子关系:子节点独立成列(整列距右移),后续列顺延,连线统一等长', () => {
+    const data = laneTopo();
+    data.links.push({ source: 'point:b1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' });
     const layout = computeLayout(data, 1200, 700);
-    expect(layout.mode).toBe('linear');
-    expect(layout.positions['point:u1'].x).toBe(layout.positions['point:e1'].x + colGap);
+    const pos = layout.positions;
+    // b2 独立成列:位于 b1(lv2 列)右侧一整列距,连线与 lv1→lv2 等长
+    expect(pos['point:b2'].x).toBe(pos['point:b1'].x + colGap);
+    // 后续列(lv3/lv4/lv5/地面)顺延右移一列
+    expect(pos['point:c1'].x).toBe(MARGIN + 4 * colGap);
+    expect(pos['point:d1'].x).toBe(MARGIN + 5 * colGap);
+    expect(pos[ROOT_OUT_ID].x).toBe(MARGIN + 8 * colGap);
+    // 独立成列后各自垂直居中,y 对齐
+    expect(pos['point:b1'].y).toBe(centerY);
+    expect(pos['point:b2'].y).toBe(centerY);
   });
 
-  test('跨级边(无 L4 时 L3→L5)不影响列式布局', () => {
-    const data = schematicTopo();
-    data.nodes = data.nodes.filter((n) => n.id !== 'point:d1');
-    data.links = data.links.filter((l) => l.source !== 'point:d1' && l.target !== 'point:d1');
-    data.links.push({ source: 'point:c1', target: 'point:e1', kind: 'roadway', flow: 'return', pointId: 'point:e1' });
-    const layout = computeLayout(data, 1200, 700);
-    expect(layout.mode).toBe('linear');
-    expect(layout.hiddenNodeIds.size).toBe(0);
-    // L5 仍在固定层级列(L4 缺失不影响列位,位于 L3 右侧两列处)
-    expect(layout.positions['point:e1'].x).toBe(MARGIN + 5 * colGap);
-    expect(layout.positions['point:e1'].x).toBeGreaterThan(layout.positions['point:c1'].x);
+  test('同层点位列内上下对称等距:间距 = min(该层级 columnCap, 可用高度均分),以 centerY 居中', () => {
+    const layout = computeLayout(laneTopo(), 1200, 700);
+    const pos = layout.positions;
+    const cap = LAYOUT.columnCap[2] ?? LAYOUT.columnCapDefault;
+    const expected = Math.min(cap, 200); // maxSpread = min(200, centerY-40) = 200
+    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('无 L1 时 L2 作为首列(矿井右侧第一列)', () => {
-    const data = schematicTopo();
-    data.nodes = data.nodes.filter((n) => n.id !== 'point:a1');
-    data.links = data.links.filter((l) => l.source !== 'point:a1' && l.target !== 'point:a1');
+  test('未分级点位在 lv5 与地面之间一列', () => {
+    const data = laneTopo();
+    data.nodes.push({ id: 'point:u1', name: '未分级', category: 1 });
     const layout = computeLayout(data, 1200, 700);
-    expect(layout.mode).toBe('linear');
-    expect(layout.hiddenNodeIds.size).toBe(0);
-    expect(layout.positions['org:m1'].x).toBeLessThan(layout.positions['point:b1'].x);
-    expect(layout.positions['point:b1'].x).toBeLessThan(layout.positions['point:c1'].x);
+    expect(layout.positions['point:u1'].x).toBe(laneX(6));
+    expect(layout.positions['point:u1'].x).toBeGreaterThan(layout.positions['point:e1'].x);
+    expect(layout.positions['point:u1'].x).toBeLessThan(layout.positions[ROOT_OUT_ID].x);
+  });
+
+  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();
+  });
+});
+
+describe('computeLayout 链式顺序布局', () => {
+  test('提供链式结构时各层列内按 (链下标, 链内位置) 排序:链间上下并行、同链路径顺序', () => {
+    // 两条链共享前缀 a1:a1→b1→c1→d1→e1(链0)与 a1→b2→c2→d2→e2(链1);
+    // 链1 节点风量故意更高:若未按链排序(回退风量降序)b2 会排在 b1 上方
+    const areas = [
+      area('a1', 1, 100),
+      area('b1', 2, 60),
+      area('b2', 2, 90),
+      area('c1', 3, 40),
+      area('c2', 3, 80),
+      area('d1', 4, 30),
+      area('d2', 4, 70),
+      area('e1', 5, 20),
+      area('e2', 5, 60),
+    ];
+    const relations = [
+      rel('r1', 'a1', 'b1'),
+      rel('r2', 'a1', 'b2'),
+      rel('r3', 'b1', 'c1'),
+      rel('r4', 'b2', 'c2'),
+      rel('r5', 'c1', 'd1'),
+      rel('r6', 'c2', 'd2'),
+      rel('r7', 'd1', 'e1'),
+      rel('r8', 'd2', 'e2'),
+    ];
+    const relationArr = buildRelationArray(areas, relations);
+    const chains = buildChains(relationArr);
+    const data = transformToTopologyData(areas, relationArr, []);
+    const layout = computeLayout(data, 1200, 700, chains);
+    const pos = layout.positions;
+    // 链间上下并行:链0(b1/c1/d1)在链1(b2/c2/d2)上方
+    expect(pos['point:b1'].y).toBeLessThan(pos['point:b2'].y);
+    expect(pos['point:c1'].y).toBeLessThan(pos['point:c2'].y);
+    expect(pos['point:d1'].y).toBeLessThan(pos['point:d2'].y);
+    // 同链路径顺序:链0 各层节点均为列内首位,y 一致(每列同序、间距相同)
+    expect(pos['point:b1'].y).toBe(pos['point:c1'].y);
+    expect(pos['point:c1'].y).toBe(pos['point:d1'].y);
+    expect(pos['point:d1'].y).toBe(pos['point:e1'].y);
+    // 对比:不传链式结构时回退风量降序,风量更高的链1 在上(证明链排序生效)
+    const fallback = computeLayout(data, 1200, 700);
+    expect(fallback.positions['point:b2'].y).toBeLessThan(fallback.positions['point:b1'].y);
+  });
+});
+
+describe('computeLayout 同级连线平行(getMineAreaRelation 真实数据)', () => {
+  /** 真实返回:9 个测风点位(id=1..9)+ 9 条巷道关系(id=5/6 为两条 lv3 同级连线 4→6、5→7) */
+  const realAreas = () => [
+    area('1', 1, 5125, { windrectId: '1' }),
+    area('2', 2, 456, { windrectId: '2' }),
+    area('3', 2, 568, { windrectId: '3' }),
+    area('4', 3, 458, { windrectId: null }),
+    area('5', 3, 895, { windrectId: '5' }),
+    area('6', 3, 425, { windrectId: '6' }),
+    area('7', 3, 1246, { windrectId: '7' }),
+    area('8', 4, 2895, { windrectId: '8' }),
+    area('9', 5, 5725, { windrectId: '9' }),
+  ];
+  const realRels = () => [
+    rel('1', '1', '2'),
+    rel('2', '1', '3'),
+    rel('3', '2', '4'),
+    rel('4', '3', '5'),
+    rel('5', '4', '6'),
+    rel('6', '5', '7'),
+    rel('7', '6', '8'),
+    rel('8', '7', '8'),
+    rel('9', '8', '9'),
+  ];
+  const laneX = (idx: number) => 60 + 300 * idx;
+  const layoutOf = (rels: ReturnType<typeof realRels>) => {
+    const relationArr = buildRelationArray(realAreas(), rels);
+    const chains = buildChains(relationArr);
+    return computeLayout(transformToTopologyData(realAreas(), relationArr, []), 1200, 700, chains);
+  };
+
+  test('列序快照 + 两条 lv3 同级连线(4→6、5→7)平行:子节点共列、y 镜像父节点', () => {
+    const pos = layoutOf(realRels()).positions;
+    // 列序:地面 → lv1 → lv2 → lv3(父) → lv3(子) → lv4 → lv5 → 地面
+    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
+    expect(pos['point:1'].x).toBe(laneX(1));
+    expect(pos['point:2'].x).toBe(laneX(2));
+    expect(pos['point:3'].x).toBe(laneX(2));
+    expect(pos['point:4'].x).toBe(laneX(3));
+    expect(pos['point:5'].x).toBe(laneX(3));
+    expect(pos['point:6'].x).toBe(laneX(4));
+    expect(pos['point:7'].x).toBe(laneX(4));
+    expect(pos['point:8'].x).toBe(laneX(5));
+    expect(pos['point:9'].x).toBe(laneX(6));
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(8));
+    // 平行:子节点 6、7 共列(同一 x),且纵向镜像父节点顺序 → 连线 4→6、5→7 水平平行、等长
+    expect(pos['point:4'].x).toBe(pos['point:5'].x);
+    expect(pos['point:6'].x).toBe(pos['point:7'].x);
+    expect(pos['point:6'].y).toBe(pos['point:4'].y);
+    expect(pos['point:7'].y).toBe(pos['point:5'].y);
   });
 
-  test('无任何点位时仍正常定位矿井且不抛错', () => {
-    const layout = computeLayout({ nodes: [{ id: 'org:m1', name: '某矿', category: 0 }], links: [] }, 1200, 700);
-    expect(layout.mode).toBe('linear');
-    expect(layout.positions['org:m1']).toBeDefined();
+  test('关系列表倒序返回时,两条 lv3 同级连线仍平行(不依赖 API 顺序)', () => {
+    const pos = layoutOf(realRels().reverse()).positions;
+    // 平行保证:子节点共列、y 镜像父节点 → 连线 4→6、5→7 水平平行、配对不变、互不交叉
+    expect(pos['point:6'].x).toBe(pos['point:7'].x);
+    expect(pos['point:6'].y).toBe(pos['point:4'].y);
+    expect(pos['point:7'].y).toBe(pos['point:5'].y);
+    // 注:buildChains 按关系首次出现顺序选主链,倒序时链0/链1 的上下槽位可能互换,
+    // 但平行性与父子配对关系不变(API 按 createTime 稳定返回时为确定布局)
   });
 });
 
 describe('computeLayout 列内纵向间距按层级分别配置', () => {
-  // 保存被测试改写的间距配置,避免影响其他用例
-  const origCap1 = LAYOUT.columnCap[1];
   const origCap2 = LAYOUT.columnCap[2];
-  const origCap3 = LAYOUT.columnCap[3];
   afterEach(() => {
-    LAYOUT.columnCap[1] = origCap1;
     LAYOUT.columnCap[2] = origCap2;
-    LAYOUT.columnCap[3] = origCap3;
   });
 
-  test('L1/L3 配置不同间距上限时各列内间距分别生效,未配置层级回退默认', () => {
-    LAYOUT.columnCap[1] = 50;
-    LAYOUT.columnCap[3] = 130;
-    delete LAYOUT.columnCap[2]; // 未配置 → 回退 columnCapDefault
-    const data: TopologyData = {
-      nodes: [
-        { id: 'org:m1', name: '某矿', category: 0 },
-        { id: 'point:a1', name: 'L1a', category: 1, level: 1 },
-        { id: 'point:a2', name: 'L1b', category: 1, level: 1 },
-        { id: 'point:b1', name: 'L2a', category: 1, level: 2 },
-        { id: 'point:b2', name: 'L2b', category: 1, level: 2 },
-        { id: 'point:c1', name: 'L3a', category: 1, level: 3 },
-        { id: 'point:c2', name: 'L3b', category: 1, level: 3 },
-        { id: 'point:d1', name: 'L4a', category: 1, level: 4 },
-        { id: 'point:e1', name: 'L5a', category: 1, level: 5 },
-      ],
-      links: [],
-    } as unknown as TopologyData;
+  test('配置不同间距上限时列内间距分别生效', () => {
+    LAYOUT.columnCap[2] = 50;
+    const layout = computeLayout(laneTopo(), 1200, 700);
+    expect(Math.abs(layout.positions['point:b2'].y - layout.positions['point:b1'].y)).toBe(50);
+  });
+});
+
+describe('computeLayout LAYOUT 按 level 配置生效', () => {
+  // 保存原始配置,用例结束后恢复,避免影响其他用例
+  const orig = {
+    minEdge: { ...LAYOUT.minEdgeLength },
+    minGap: { ...LAYOUT.columnMinGap },
+    placement: { ...LAYOUT.devicePlacement },
+    offset: { ...LAYOUT.deviceOffset },
+  };
+  afterEach(() => {
+    Object.assign(LAYOUT.minEdgeLength, orig.minEdge);
+    Object.assign(LAYOUT.columnMinGap, orig.minGap);
+    Object.assign(LAYOUT.devicePlacement, orig.placement);
+    Object.assign(LAYOUT.deviceOffset, orig.offset);
+  });
+
+  test('minEdgeLength 按 level 生效:同级子列水平偏移 = 父列 x + max(sameLevelOffset, minEdgeLength[lv])', () => {
+    LAYOUT.minEdgeLength[2] = 500;
+    const data = laneTopo();
+    data.links.push({ source: 'point:b1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' });
     const layout = computeLayout(data, 1200, 700);
-    const pos = layout.positions;
-    const centerY = 700 / 2; // 无未绑定布点 → 底部无预留
-    // 各列 2 个点位且可用高度充足(maxSpread = min(200, centerY-40) = 200)→ 间距 = 配置值
-    expect(Math.abs(pos['point:a2'].y - pos['point:a1'].y)).toBe(50); // L1 cap=50
-    expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(Math.min(LAYOUT.columnCapDefault, 200)); // L2 回退默认
-    expect(Math.abs(pos['point:c2'].y - pos['point:c1'].y)).toBe(130); // L3 cap=130
-    // 单点列(L4/L5)垂直居中
-    expect(pos['point:d1'].y).toBe(centerY);
-    expect(pos['point:e1'].y).toBe(centerY);
+    // b2(lv2 同级子列)x = b1.x + max(200, 500) = b1.x + 500
+    expect(layout.positions['point:b2'].x).toBe(layout.positions['point:b1'].x + 500);
+    // 后续列仍按 laneX 顺延(c1 为 lv3 列,索引 +1)
+    expect(layout.positions['point:c1'].x).toBe(60 + 4 * 300);
+  });
+
+  test('columnMinGap 按 level 生效:列内纵向间距不低于下限', () => {
+    LAYOUT.columnMinGap[2] = 400;
+    // laneTopo 的 lv2 列(b1、b2)可用均分 = 200 < 400 → 间距被下限钳制为 400
+    const layout = computeLayout(laneTopo(), 1200, 700);
+    expect(Math.abs(layout.positions['point:b2'].y - layout.positions['point:b1'].y)).toBe(400);
+    // 未配置的层级(lv3 单节点列)不受影响
+    expect(layout.positions['point:c1'].y).toBe((700 - 90) / 2);
+  });
+
+  test('devicePlacement/deviceOffset 按 level 生效:改 lv3 布点方向与距离', () => {
+    LAYOUT.devicePlacement[3] = 'left';
+    LAYOUT.deviceOffset[3] = 60;
+    const data = laneTopo();
+    data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
+    data.links.push({ source: 'point:c1', target: 'device:w3', kind: 'sensor' });
+    const layout = computeLayout(data, 1200, 700);
+    expect(layout.devicePlacement['device:w3']).toBe('left');
+    expect(layout.positions['device:w3'].x).toBe(layout.positions['point:c1'].x - 60);
+  });
+
+  test('devicePlacement 按 level 生效:改 lv1 布点方向为右侧', () => {
+    LAYOUT.devicePlacement[1] = 'right';
+    const data = laneTopo();
+    data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
+    data.links.push({ source: 'point:a1', target: 'device:w3', kind: 'sensor' });
+    const layout = computeLayout(data, 1200, 700);
+    expect(layout.devicePlacement['device:w3']).toBe('right');
+    expect(layout.positions['device:w3'].x).toBe(layout.positions['point:a1'].x + LAYOUT.deviceOffset[1]);
   });
 });