فهرست منبع

[Wip 0000] 拓扑图功能开发

houzekong 2 هفته پیش
والد
کامیت
eafc4dfef8

+ 13 - 11
src/views/analysis/warningAnalysis/windPointManage/windPointManage.data.ts

@@ -80,12 +80,12 @@ export const pointColumns: BasicColumn[] = [
 
 /** 上传数据布点表格列(Windrect) */
 export const windrectColumns: BasicColumn[] = [
-  {
-    title: '设备编码',
-    dataIndex: 'deviceCode',
-    width: 130,
-    fixed: 'left',
-  },
+  // {
+  //   title: '设备编码',
+  //   dataIndex: 'deviceCode',
+  //   width: 130,
+  //   fixed: 'left',
+  // },
   {
     title: '设备位置',
     dataIndex: 'devicePos',
@@ -146,6 +146,7 @@ export const formSchema: FormSchema[] = [
     label: '层级',
     field: 'level',
     component: 'Select',
+    required: true,
     componentProps: {
       placeholder: '请选择层级',
       options: [
@@ -158,31 +159,32 @@ export const formSchema: FormSchema[] = [
     },
   },
   {
-    label: '风量(m³/min)',
+    label: '风量(m³/min)',
     field: 'airVolume',
     component: 'InputNumber',
+    required: true,
     componentProps: {
-      placeholder: '请输入风量',
+      placeholder: '请输入风量',
       min: 0,
       style: 'width: 100%',
     },
   },
   {
-    label: '规程值',
+    label: '巷道分类',
     field: 'regulationId',
     component: 'ApiSelect',
+    required: true,
     componentProps: {
       api: getWindrectRegulation,
       labelField: 'name',
       valueField: 'id',
-      placeholder: '请选择规程值',
+      placeholder: '请选择巷道分类',
     },
   },
   {
     label: '测风设备',
     field: 'windrectId',
     component: 'ApiSelect',
-    required: true,
     componentProps: {
       api: getWindrectList,
       labelField: 'devicePos',

+ 172 - 147
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts

@@ -9,17 +9,16 @@ import {
   statusColorMap,
   levelColorMap,
   nodeDetailFields,
-  weightKey,
   transformToTopologyData,
   collectOrgSubtree,
-  LAYOUT_WIDTH,
-  LAYOUT_HEIGHT,
+  roadwayColorMap,
+  formatAirVolumeLabel,
 } from '../windTopology.data';
 import type { TopologyData, TopoNodeData } from '../windTopology.data';
-import { getTopologyData, updateArea } from '../windTopology.api';
+import { getTopologyData, updateArea, deleteMineArea } from '../windTopology.api';
+import { computeLayout } from './useTopologyLayout';
 
 /** 节点分类索引:0=部门(只读),1=测风点位,2=数据布点 */
-const CAT_ORG = 0;
 const CAT_POINT = 1;
 const CAT_DEVICE = 2;
 
@@ -63,75 +62,6 @@ export function useTopology() {
     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();
@@ -195,6 +125,12 @@ export function useTopology() {
     const option: EChartsOption = createGraphOption();
     const series: any = (option.series as any[])[0];
 
+    const cw = chartInstance.getWidth();
+    const ch = chartInstance.getHeight();
+    // 通风示意图布局(不满足条件自动退级普通流程图)
+    const layout = computeLayout(data, cw, ch);
+    const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
+
     // 最终按 id 去重兜底(数据层已去重,此处防止异常数据导致 ECharts 重复 id 报错)
     const seenIds = new Set<string>();
     const echartsNodes: any[] = [];
@@ -212,7 +148,7 @@ export function useTopology() {
       //   部门/矿端(category 0)使用分类色(紫色),不再被 normal 状态色统一覆盖
       let nodeColor = cat.color;
       if (n.category === 1) {
-        nodeColor = levelColorMap[n.level] || cat.color;
+        nodeColor = levelColorMap[Number(n.level)] || cat.color;
       } else if (n.category === 2) {
         nodeColor = statusColorMap[n.status || 'normal'] || cat.color;
       }
@@ -222,27 +158,75 @@ export function useTopology() {
         itemStyle.borderColor = '#ff4d4f';
         itemStyle.borderWidth = 3;
       }
-      echartsNodes.push({
+      // 测风点位:非用风(L3)点位以末端小点呈现(巷道连线末端),L3 用风保持节点样式
+      const isEndDot = n.category === 1 && Number(n.level) !== 3;
+      if (isEndDot) {
+        itemStyle.borderColor = '#ffffff';
+        itemStyle.borderWidth = 1.5;
+      }
+      const pos = layout.positions[n.id];
+      const en: any = {
         id: n.id,
         name: n.name,
         category: n.category,
         value: n.name,
-        symbol: cat.symbol,
-        symbolSize: isMarked ? cat.symbolSize + 8 : cat.symbolSize,
+        symbol: isEndDot ? 'circle' : cat.symbol,
+        symbolSize: isEndDot ? 8 : isMarked ? cat.symbolSize + 8 : cat.symbolSize,
         itemStyle,
         raw: n,
-      });
+      };
+      if (pos) {
+        en.x = pos.x;
+        en.y = pos.y;
+        en.fixed = !forceUnfixed;
+      }
+      echartsNodes.push(en);
     }
 
-    const cw = chartInstance.getWidth();
-    const ch = chartInstance.getHeight();
-    applyHierarchicalLayout(echartsNodes, forceUnfixed, cw, ch);
+    // 绘制层级:同一 series 内后绘制者在上。按优先级排序使「数据布点」置于最底、「测风点位」在其上方,
+    // 避免布点(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) => ({
-      source: l.source,
-      target: l.target,
-      kind: l.kind,
-    }));
+    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,
+          kind: l.kind,
+          pointId: l.pointId,
+          flow: l.flow,
+        };
+        if (l.kind === 'roadway') {
+          // 巷道连线:进风/回风分色 + 风流方向箭头 + 轻微曲线;连线标注所属测风点位风量
+          const point = l.pointId ? pointById.get(l.pointId) : undefined;
+          link.flowLabel = formatAirVolumeLabel(point?.airVolume);
+          link.lineStyle = {
+            color: roadwayColorMap[l.flow || 'intake'],
+            width: 3,
+            opacity: 0.85,
+            curveness: 0.2,
+            ...(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 };
+        }
+        return link;
+      })
+      .filter(Boolean);
 
     if (editMode.value) {
       series.lineStyle = { ...series.lineStyle, type: 'dashed' as const, width: 2, opacity: 0.6 };
@@ -273,33 +257,70 @@ export function useTopology() {
 
       if (params.dataType === 'node') {
         if (editMode.value && selectMode.value) {
-          // 选择态下单击另一节点 → 尝试绑定
-          attemptBind(selectedSourceId.value, params.data.id);
+          // 选择态下单击节点 → 尝试绑定(仅测风点位为绑定目标)
+          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);
+        }
       }
     });
 
     // ——— 双击 ———
     chartInstance.on('dblclick', (params: any) => {
       if (!params.data) return;
+      // 双击交互(删除/绑定/解绑)仅在绑定模式下生效
+      if (!editMode.value) return;
 
-      if (params.dataType === 'edge' && editMode.value) {
-        // 双击连线 → 解绑(仅测风点位↔数据布点)
-        handleUnbind(params.data.source, params.data.target, params.data.kind);
+      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;
+        }
         return;
       }
 
-      if (params.dataType === 'node' && editMode.value) {
-        // 「测风点位」作为绑定到矿井的源;「数据布点」作为绑定到测风点位的源
-        if (params.data.category === CAT_POINT || params.data.category === CAT_DEVICE) {
+      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);
-        } else {
-          message.info('请双击「测风点位」或「数据布点」节点作为绑定源');
+          return;
         }
+        message.info('矿井节点不可删除');
       }
     });
 
@@ -315,8 +336,8 @@ export function useTopology() {
   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('请双击「测风点位」或「数据布点」节点作为绑定源');
+    if (src.category !== CAT_DEVICE) {
+      message.info('请双击「数据布点」节点作为绑定源');
       return;
     }
     selectMode.value = true;
@@ -325,11 +346,7 @@ export function useTopology() {
     // 重绘应用放大 + 描边标记,并高亮关联连线
     renderTopology(topologyData);
     highlightPath(sourceId);
-    const tip =
-      src.category === CAT_POINT
-        ? `已选中"${src.name}",请单击一个「矿井」或「数据布点」绑定。单击空白处取消。`
-        : `已选中"${src.name}",请单击一个「测风点位」绑定。单击空白处取消。`;
-    message.info(tip);
+    message.info(`已选中"${src.name}",请单击一个巷道连线或用风节点绑定。单击空白处取消。`);
   }
 
   function exitSelectMode() {
@@ -368,62 +385,70 @@ export function useTopology() {
     });
   }
 
-  async function attemptBind(sourceId: string, targetId: string) {
+  /**
+   * 绑定/换绑:数据布点(源)→ 测风点位(目标),编辑测风点位的 windrectId
+   * (后端一次调用替换旧绑定);目标点位可由巷道连线(roadway 边 pointId)或用风节点解析。
+   */
+  function attemptBind(sourceId: string, pointId: string) {
     exitSelectMode();
-    if (sourceId === targetId) {
+    if (sourceId === pointId) {
       message.warning('不能绑定到自身');
       return;
     }
     const src = topologyData.nodes.find((n) => n.id === sourceId);
-    const tgt = topologyData.nodes.find((n) => n.id === targetId);
+    const tgt = topologyData.nodes.find((n) => n.id === pointId);
     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 });
+    // 仅支持:数据布点(源)→ 测风点位(目标)
+    if (src.category !== CAT_DEVICE || tgt.category !== CAT_POINT) {
+      message.warning('仅支持「数据布点」绑定到「测风点位」');
       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('仅支持「测风点位→矿井/数据布点」或「数据布点→测风点位」的绑定');
+    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 });
+  }
 
-    const alreadyLinked = topologyData.links.some((l) => l.kind === 'sensor' && l.source === pointNode.id && l.target === deviceNode.id);
-    if (alreadyLinked) {
-      message.warning('已存在该绑定关系');
+  // ==================== 删除测风点位 ====================
+
+  /** 双击巷道连线/用风节点删除测风点位(绑定模式下),调 deleteMineArea 删除原始记录 */
+  function handleDeletePoint(pointId?: string) {
+    if (!pointId) {
+      message.warning('该连线不可删除');
+      return;
+    }
+    const point = topologyData.nodes.find((n) => n.id === pointId);
+    if (!point || point.category !== CAT_POINT) {
+      message.warning('该连线不可删除');
+      return;
+    }
+    if (!point.rawId) {
+      message.warning('缺少测风点位原始 id,无法删除');
       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 });
+    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('删除失败');
+        }
+      },
+    });
   }
 
   // ==================== 解绑逻辑 ====================
@@ -533,7 +558,7 @@ export function useTopology() {
     if (on) {
       renderTopology(topologyData, true);
       chartInstance.setOption({ series: [{ roam: false }] as any });
-      message.info('绑定模式已开启:双击「测风点位」→ 单击「矿井」或「数据布点」绑定;双击「数据布点」→ 单击「测风点位」绑定;双击连线解绑');
+      message.info('绑定模式已开启:双击「数据布点」→ 单击巷道连线/末端小点或用风节点绑定/换绑;双击巷道连线/用风节点删除测风点位;双击短连线解绑');
     } else {
       renderTopology(topologyData);
       message.info('已退出绑定模式');

+ 63 - 35
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts.bak.txt

@@ -7,6 +7,7 @@ import {
   createGraphOption,
   categories,
   statusColorMap,
+  levelColorMap,
   nodeDetailFields,
   weightKey,
   transformToTopologyData,
@@ -14,7 +15,7 @@ import {
   LAYOUT_WIDTH,
   LAYOUT_HEIGHT,
 } from '../windTopology.data';
-import type { TopologyData, TopoNodeData, OrgPathNode } from '../windTopology.data';
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
 import { getTopologyData, updateArea } from '../windTopology.api';
 
 /** 节点分类索引:0=部门(只读),1=测风点位,2=数据布点 */
@@ -69,15 +70,21 @@ export function useTopology() {
     if (chartW < 100) chartW = LAYOUT_WIDTH;
     if (chartH < 100) chartH = LAYOUT_HEIGHT;
 
-    // 行序列:部门节点按树层级(depth)逐层分组为多行,测风点位/数据布点各占一行
+    // 行序列:部门节点按树层级(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);
@@ -87,7 +94,11 @@ export function useTopology() {
       .map(Number)
       .sort((a, b) => a - b);
     for (const d of depths) rows.push({ list: depthGroups[d] });
-    if (catGroups[1]) rows.push({ list: catGroups[1], weight: weightKey[1] });
+    // 测风点位按层级 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;
@@ -124,36 +135,31 @@ export function useTopology() {
   async function loadTopology() {
     try {
       const mineStore = useMineDepartmentStore();
-      // 任意层级部门均可过滤:选中部门 → 部门层为其子树(该部门及全部后代),数据按该部门 deptId 查询
-      const dept = selectedDeptId.value ? mineStore.findDepartById(selectedDeptId.value) : undefined;
-
-      let orgNodes: OrgPathNode[];
-      let queryDeptId: string | undefined;
-      if (dept) {
-        orgNodes = collectOrgSubtree(dept);
-        queryDeptId = dept.id;
-      } else {
-        let root = mineStore.getRoot;
-        if (!root) {
-          // 组织树未就绪(极端情况):短暂等待后重试一次
-          console.warn('组织树未就绪,300ms 后重试加载拓扑');
-          await new Promise((r) => setTimeout(r, 300));
-          root = mineStore.getRoot;
-        }
-        if (!root) {
-          console.error('组织树仍未就绪,无法加载拓扑');
-          message.error('组织树未就绪,无法加载拓扑');
-          return;
-        }
-        // 全局视图:部门层 = 当前用户部门(getRoot)及其全部后代
-        orgNodes = collectOrgSubtree(root);
+      // 仅支持矿端(叶子节点)视图:deptId 必须是组织树中的矿井叶子;
+      // 未选中或选中非矿端(根/中间部门)时自动定位到第一个矿端
+      let mine = selectedDeptId.value ? mineStore.findDepartById(selectedDeptId.value) : undefined;
+      if (!mine || !mine.isLeaf) {
+        mine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
       }
-
-      const apiData = await getTopologyData(queryDeptId ? { deptId: queryDeptId } : {});
+      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=' + (queryDeptId || mineStore.getRootId || '') + ')');
+        console.warn('拓扑数据为空:未获取到测风点位/数据布点(deptId=' + mine.id + ')');
       }
     } catch (e) {
       console.error('拓扑数据加载失败:', e);
@@ -161,10 +167,23 @@ export function useTopology() {
     }
   }
 
-  /** 操作栏部门选择:过滤部门树(该部门及后代)并按所选部门查询数据。
-   *  注意:v-model 已先行更新 selectedDeptId,此处只需直接触发加载(不能按值去抖,否则恒等跳过) */
+  /** 操作栏煤矿选择:仅允许选择矿端(叶子节点)并按所选矿查询数据。
+   *  MineCascader 已设 change-on-select=false,change 仅在选中叶子(矿端)时触发;
+   *  此处再兜底校验,非矿端(根/中间部门/空值)自动回退到第一个矿端。
+   *  注意:需先更新 selectedDeptId 再加载(不能按值去抖,否则恒等跳过) */
   function setSelectedDept(id: string) {
-    selectedDeptId.value = id || '';
+    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();
   }
 
@@ -186,9 +205,18 @@ export function useTopology() {
       }
       seenIds.add(n.id);
       const cat = categories[n.category] || categories[0];
-      const statusColor = statusColorMap[n.status || 'normal'];
       const isMarked = n.id === markedSourceId.value;
-      const itemStyle: any = { color: statusColor || cat.color };
+      // 节点配色按类型区分:
+      //   测风点位(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';

+ 137 - 0
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts

@@ -0,0 +1,137 @@
+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';
+
+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;
+
+/**
+ * 从左到右列式布局:
+ *   矿井位于最左、垂直居中;L1→L2→L3→L4→L5 各占一列从左到右依次排布,未分级点位在最后一列;
+ *   同层点位在列内按层级内稳定排序上下对称均匀分布(保证连线不交叉),整图不闭合(不绘制回风闭合边)。
+ * 数据布点:已绑定 → 测风点位正下方(node-side);未绑定 → 图下方一行。
+ */
+export function computeLayout(data: TopologyData, width: number, height: number): 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 points = data.nodes.filter((n) => n.category === 1);
+  const devices = data.nodes.filter((n) => n.category === 2);
+
+  // 按层级分组;未分级点位单独归列
+  const byLevel = new Map<number, TopoNodeData[]>();
+  const ungraded: TopoNodeData[] = [];
+  for (const p of points) {
+    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);
+    }
+  }
+
+  // roadway 边:pointId 指向连线所属点位;parentOf 仅用于层级内稳定排序(保证连线不交叉)
+  const parentOf: Record<string, string> = {};
+  for (const l of data.links) {
+    if (l.kind === 'roadway' && l.pointId) {
+      parentOf[l.target] = l.source;
+    }
+  }
+
+  // 层级内稳定排序:L1 按风量降序,其后各层按父点位顺序分组、组内风量降序
+  const levelOrder: Record<number, TopoNodeData[]> = {};
+  {
+    const parentIndex = new Map<string, number>();
+    const orderStack: string[] = [];
+    const rank = (id?: string) => (id && parentIndex.has(id) ? parentIndex.get(id)! : -1);
+    for (let lv = 1; lv <= 5; lv++) {
+      const list = byLevel.get(lv) || [];
+      const sorted = [...list].sort((a, b) => {
+        const ra = rank(parentOf[a.id]);
+        const rb = rank(parentOf[b.id]);
+        if (ra !== rb) return ra - rb;
+        return (b.airVolume ?? 0) - (a.airVolume ?? 0);
+      });
+      levelOrder[lv] = sorted;
+      for (const p of sorted) {
+        parentIndex.set(p.id, orderStack.length);
+        orderStack.push(p.id);
+      }
+    }
+  }
+
+  // 数据布点 → 绑定点位(sensor 边 source=点位、target=布点)
+  const boundPointOf: Record<string, string> = {};
+  for (const l of data.links) {
+    if (l.kind === 'sensor') boundPointOf[l.target] = l.source;
+  }
+
+  const unboundCount = devices.filter((d) => !boundPointOf[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 };
+    });
+  };
+
+  // 从左到右列式布局:矿井 → 存在层级列(1..5)→ 未分级列(不闭合)
+  // 各列内纵向间距上限按层级分别配置(columnCap),未配置的层级回退 columnCapDefault
+  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;
+  }
+  if (ungraded.length) placeColumn(ungraded, colX, centerY, maxSpread, LAYOUT.columnCap.ungraded ?? LAYOUT.columnCapDefault);
+
+  // 数据布点:已绑定 → 测风点位正下方(node-side);未绑定 → 图下方一行
+  const bottom: TopoNodeData[] = [];
+  for (const d of devices) {
+    const pid = boundPointOf[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';
+  }
+  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;
+    bottom.forEach((d, i) => {
+      positions[d.id] = { x: x0 + i * gap, y: H - LAYOUT.unboundRowGap };
+    });
+  }
+
+  return { mode: 'linear', positions, hiddenNodeIds, devicePlacement };
+}

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

@@ -33,7 +33,7 @@
         </a-button>
         <a-divider type="vertical" />
         <span v-if="editMode" class="select-text"
-          >双击「测风点位」→ 单击「矿井」或「数据布点」绑定;双击「数据布点」→ 单击「测风点位」绑定;双击连线解绑</span
+          >双击「数据布点」→ 单击巷道连线/末端小点或用风节点绑定/换绑;双击巷道连线/用风节点删除测风点位;双击短连线解绑</span
         >
       </div>
 
@@ -48,6 +48,22 @@
             <span class="legend-dot" :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>
             数据布点·正常
@@ -55,7 +71,7 @@
           <span class="legend-item">
             <span class="legend-dot" :style="{ background: statusColorMap.offline }"></span>
             数据布点·离线
-          </span>
+          </span> -->
         </div>
       </div>
     </div>

+ 33 - 13
src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue.bak.txt

@@ -5,13 +5,14 @@
     <div class="topo-toolbar">
       <div class="toolbar-left">
         <div class="mine-select">
-          <span class="select-label">部门:</span>
+          <span class="select-label">煤矿:</span>
           <MineCascader
             v-model:value="selectedDeptId"
             style="width: 220px"
             :init-from-store="false"
             :sync-from-store="false"
-            :change-on-select="true"
+            :change-on-select="false"
+            placeholder="请选择煤矿"
             @change="setSelectedDept"
           />
         </div>
@@ -37,22 +38,26 @@
       </div>
 
       <!-- 图例 -->
-      <!-- <div class="toolbar-right">
+      <div class="toolbar-right">
         <div class="legend">
-          <span v-for="cat in categories" :key="cat.name" class="legend-item">
-            <span class="legend-dot" :style="{ background: cat.color }"></span>
-            {{ cat.name }}
+          <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: #fa8c16"></span>
-            异常
+            <span class="legend-dot" :style="{ background: statusColorMap.normal }"></span>
+            数据布点·正
           </span>
           <span class="legend-item">
-            <span class="legend-dot" style="background: #999"></span>
-            离线
+            <span class="legend-dot" :style="{ background: statusColorMap.offline }"></span>
+            数据布点·离线
           </span>
         </div>
-      </div> -->
+      </div>
     </div>
 
     <!-- 主体 -->
@@ -65,7 +70,7 @@
           <span class="detail-title">节点详情</span>
           <a-button size="small" type="text" @click="clearSelection">✕</a-button>
         </div>
-        <div class="detail-category" :style="{ color: categories[selectedNode.category]?.color }">
+        <div class="detail-category" :style="{ color: nodeColor(selectedNode) }">
           {{ categories[selectedNode.category]?.name }}
         </div>
         <div class="detail-name">{{ selectedNode.name }}</div>
@@ -84,9 +89,22 @@
   import { onMounted, onUnmounted } from 'vue';
   import { SvgIcon } from '/@/components/Icon';
   import MineCascader from '/@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
-  import { categories } from './windTopology.data';
+  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,
@@ -163,6 +181,8 @@
 
     .toolbar-right .legend {
       display: flex;
+      flex-wrap: wrap;
+      justify-content: flex-end;
       gap: 12px;
       align-items: center;
       .legend-item {

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

@@ -6,6 +6,7 @@ enum Api {
   getMineAreaList = '/workingface/mineArea/getMineAreaList',
   getWindrectList = '/workingface/windrect/getWindrectList',
   updateMineArea = '/workingface/mineArea/updateMineArea',
+  deleteMineArea = '/workingface/mineArea/deleteMineArea',
 }
 
 /**
@@ -26,15 +27,11 @@ export const getTopologyData = async (params?: any): Promise<MineAreaApiResponse
       { 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 }
-    ),
+    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), []);
+  const windrectList = windrectRes.status === 'fulfilled' ? toList(windrectRes.value) : (console.error('获取数据布点失败:', windrectRes.reason), []);
   return {
     mineAreaList: areaList as MineAreaNode[],
     mineAreaRelationList: [],
@@ -44,3 +41,6 @@ export const getTopologyData = async (params?: any): Promise<MineAreaApiResponse
 
 /** 编辑测风点位(绑定/解绑通过字段:mineCode=所属矿编码、windrectId=绑定数据布点 id) */
 export const updateArea = (params?: any) => defHttp.post({ url: Api.updateMineArea, params });
+
+/** 删除测风点位(ids 必填,逗号分隔/单 id;拓扑视图双击巷道连线删除点位时使用) */
+export const deleteMineArea = (params?: any) => defHttp.post({ url: Api.deleteMineArea, params }, { joinParamsToUrl: true });

+ 240 - 25
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts

@@ -13,7 +13,7 @@ export interface CategoryDef {
 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 },
+  { name: '数据布点', color: '#1890ff', symbol: 'roundRect', symbolSize: 18 },
 ];
 
 // ==================== 状态颜色映射 ====================
@@ -44,15 +44,37 @@ export const levelColorMap: Record<number, string> = {
   5: '#eb2f96', // 矿井回风
 };
 
-// ==================== 三层布局参数 ====================
+// ==================== 通风示意图布局参数 ====================
 
-/** 每类节点用于排序的权重字段(值越大越靠左):测风点位按已绑定数据布点数排序 */
-export const weightKey: Record<number, string> = {
-  1: 'childCount',
+/** 巷道连线配色:进风(intake)/ 回风(return) */
+export const roadwayColorMap: Record<'intake' | 'return', string> = {
+  intake: '#1677ff',
+  return: '#fa541c',
 };
 
-/** 节点横向间距基数(px),实际 = basePadding / 该层节点数 */
-export const basePadding = 800;
+/** 拓扑布局参数(px) */
+export const LAYOUT = {
+  /** 列间距(从左到右各层级列之间的水平距离) */
+  colGap: 300,
+  /** 已绑定数据布点绘制在测风点位正下方的垂直距离(即传感器连线长度) */
+  deviceOffset: 30,
+  /** 未绑定数据布点行距图底距离 */
+  unboundRowGap: 30,
+  /** 存在未绑定数据布点时底部预留高度 */
+  unboundAreaH: 90,
+  /**
+   * 各列内纵向节点间距上限(px),按层级 1..5 与未分级列分别配置;
+   * 实际间距 = min(上限, 可用高度均分),点位过多时自动压缩防溢出。
+   */
+  columnCap: { 1: 120, 2: 120, 3: 120, 4: 120, 5: 120, ungraded: 120 } as Record<number, number> & { ungraded: number },
+  /** 未在 columnCap 中配置的层级使用的间距上限(px) */
+  columnCapDefault: 200,
+};
+
+/** 巷道分支风量标注文本(通风网络图风格:在巷道连线上标注测风点位风量),无风量返回空串 */
+export function formatAirVolumeLabel(airVolume?: number): string {
+  return airVolume === undefined || airVolume === null ? '' : `${airVolume}m³/min`;
+}
 
 /** 画布宽高占位(chart 100% 容器,此处设参考值) */
 export const LAYOUT_WIDTH = 1200;
@@ -66,19 +88,28 @@ export function createGraphOption(): EChartsOption {
     series: [
       {
         type: 'graph',
-        layout: 'none', // 固定位置,由 applyHierarchicalLayout 分配坐标
+        layout: 'none', // 固定位置,由 hooks/useTopologyLayout.computeLayout 分配坐标
         roam: true,
         draggable: true, // 始终可拖,编辑模式只影响 roam
         categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
-        edgeSymbol: ['none', 'none'],
+        // 边统一带风流方向箭头(进风→右、回风→左,由 roadway 边 source→target 决定)
+        edgeSymbol: ['none', 'arrow'],
         edgeSymbolSize: [0, 10],
+        // 巷道分支风量标注(通风网络图风格:在连线上标注对应测风点位风量)
+        edgeLabel: {
+          show: true,
+          position: 'middle',
+          fontSize: 10,
+          color: '#666',
+          formatter: (p: any) => p?.data?.flowLabel || '',
+        },
         label: {
           show: true,
           position: 'bottom',
           fontSize: 11,
           formatter: (p: any) => p.name || '',
         },
-        lineStyle: { color: 'source', curveness: 0, width: 2, opacity: 0.7 },
+        lineStyle: { color: '#8c8c8c', curveness: 0, width: 2, opacity: 0.7 },
         emphasis: {
           focus: 'adjacency',
           lineStyle: { width: 3, opacity: 1 },
@@ -108,10 +139,8 @@ export const nodeDetailFields: Record<string, DetailField[]> = {
     { 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' },
@@ -149,8 +178,12 @@ export interface TopoLinkData {
   source: string;
   target: string;
   label?: string;
-  /** 连线类型:org=部门内部 / mine=测风点位→矿 / sensor=测风点位→数据布点 */
-  kind?: 'org' | 'mine' | 'sensor';
+  /** 连线类型:org=部门内部 / mine=测风点位→矿 / sensor=测风点位→数据布点 / roadway=测风点位间巷道连线 */
+  kind?: 'org' | 'mine' | 'sensor' | 'roadway';
+  /** 巷道连线所属测风点位 id(roadway 边指向子级点位;矿井闭合边无值,不可删除/绑定) */
+  pointId?: string;
+  /** 风流方向:intake=进风(左→右)/ return=回风(右→左) */
+  flow?: 'intake' | 'return';
 }
 
 export interface TopologyData {
@@ -241,21 +274,201 @@ export const statusTextMap: Record<string, string> = {
   offline: '离线',
 };
 
+/**
+ * 从测风点位名称中提取"采区标识"候选(用于同采区巷道匹配连线):
+ *   1) 数字编号优先:如 "101回风顺槽" → "101"、"3-1煤层回风" → "3-1";
+ *   2) 采区/盘区词:如 "北一采区进风" → "北一采区"、"101采区回风" → "101采区"(同时含数字 "101")。
+ * 返回按优先级排序、去重的候选 key 列表。
+ */
+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);
+  }
+  const area = name.match(/([\u4e00-\u9fa5A-Za-z0-9]+)(?:采区|盘区|区段)/);
+  if (area && !keys.includes(area[0])) keys.push(area[0]);
+  return keys;
+}
+
+/**
+ * 前端智能路径计算(不依赖关系接口,均为真实数据派生):
+ *   1) 层级跳级补齐:取实际存在的层级链(1~5 升序),相邻存在层级之间连线,
+ *      缺失层级自动跨级相连(如无 L4 时 L3 直接连 L5),保证每个测风点位都有完整回路路径;
+ *   2) 名称分组匹配:父子连线优先按"采区标识"(extractDistrictKeys)同标识匹配,
+ *      未匹配的子点退回数量均分兜底,保证每个子点恰有一条入边;
+ *   3) 矿井起止边:矿井 → 首个存在层级(进风起点)、末个存在层级 → 矿井(回风闭合边,无 pointId);
+ *   4) 未分级点位:按名称含"回风/进风"关键字判定所属侧,从对应侧(回风→末层级 / 进风→首层级 / 中段→L3)扇出。
+ * flow:目标层级 ≤3 为进风(intake),>3 为回风(return)。
+ */
+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'
+    );
+  }
+
+  // 矿井起边:矿井 → 首个存在层级;闭合边:末个存在层级 → 矿井(无 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 });
+    }
+  }
+
+  // 未分级点位:按名称关键字判定进风/回风侧,就近从对应侧扇出
+  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 (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);
+    }
+    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);
+    }
+    for (const ci of unmatched) emit(pickLeast(parentLoad, allParentIdx), ci);
+  }
+}
+
 /**
  * 将 API 响应与组织子树转换为 TopologyData(不使用 MineAreaRelation 关系表)
  * 三类节点:部门(orgNodes,只读)、测风点位(getMineAreaList 全部记录,与测风点位管理列表一致)、数据布点(Windrect)
  * 三类边(按字段派生):
  *   1) 部门内部(org parent → child)
- *   2) 测风点位 → 矿(area.mineCode 匹配部门 fax)
+ *   2) 巷道连线(roadway):buildVentilationPaths 前端智能路径计算(层级跳级补齐 + 名称分组 + 均分兜底 + 未分级侧判定
  *   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 {
+export function transformToTopologyData(orgNodes: OrgPathNode[], mineAreas: MineAreaNode[], windrects: WindrectNode[]): TopologyData {
   const prefix = (cat: string, id: any) => (id === undefined || id === null || id === '' ? '' : `${cat}:${id}`);
 
   // 部门节点:过滤缺失 id + 去重
@@ -301,11 +514,7 @@ export function transformToTopologyData(
       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' });
-  }
+  // 2) 巷道连线(roadway):由下方第 4 步生成(buildVentilationPaths 前端智能路径计算)
   // 3) 测风点位 → 数据布点
   for (const [pid, a] of pointById) {
     const deviceId = a.windrectId ? deviceRawToId.get(a.windrectId) : undefined;
@@ -314,6 +523,12 @@ export function transformToTopologyData(
       areaSensorCount[pid] = (areaSensorCount[pid] || 0) + 1;
     }
   }
+  // 4) 巷道连线(roadway):前端智能路径计算(层级跳级补齐 + 名称分组 + 均分兜底 + 未分级侧判定)
+  //    起止边(矿井→首个存在层级、末个存在层级→矿井闭合边)与未分级点位连线均在该函数内生成
+  const mineOrgId = Array.from(orgById.keys())[0] || '';
+  if (mineOrgId) {
+    for (const l of buildVentilationPaths(mineAreas, mineOrgId)) links.push(l);
+  }
 
   const nodes: TopoNodeData[] = [
     // 部门节点(只读)

+ 29 - 2
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts.bak.txt

@@ -24,6 +24,26 @@ export const statusColorMap: Record<string, string> = {
   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', // 矿井回风
+};
+
 // ==================== 三层布局参数 ====================
 
 /** 每类节点用于排序的权重字段(值越大越靠左):测风点位按已绑定数据布点数排序 */
@@ -50,7 +70,7 @@ export function createGraphOption(): EChartsOption {
         roam: true,
         draggable: true, // 始终可拖,编辑模式只影响 roam
         categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
-        edgeSymbol: ['none', 'arrow'],
+        edgeSymbol: ['none', 'none'],
         edgeSymbolSize: [0, 10],
         label: {
           show: true,
@@ -58,7 +78,7 @@ export function createGraphOption(): EChartsOption {
           fontSize: 11,
           formatter: (p: any) => p.name || '',
         },
-        lineStyle: { color: 'source', curveness: 0.3, width: 2, opacity: 0.7 },
+        lineStyle: { color: 'source', curveness: 0, width: 2, opacity: 0.7 },
         emphasis: {
           focus: 'adjacency',
           lineStyle: { width: 3, opacity: 1 },
@@ -84,6 +104,7 @@ export const nodeDetailFields: Record<string, DetailField[]> = {
   ],
   测风点位: [
     { label: '测风点位名称', key: 'name' },
+    { label: '层级', key: 'levelText' },
     { label: '所属矿井', key: 'mineName' },
     { label: '矿编码', key: 'mineCode' },
     { label: '风量(m³/min)', key: 'airVolume' },
@@ -110,6 +131,10 @@ export interface TopoNodeData {
   isLeafText?: string;
   /** 矿井编码(仅部门叶子节点(矿点)有值,测风点位→矿井绑定时取矿编码) */
   fax?: string;
+  /** 测风点位通风类型层级(1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风),用于按层级分行 */
+  level?: number;
+  /** 层级显示名(levelTextMap 映射) */
+  levelText?: string;
   status?: string; // normal / abnormal / offline
   airVolume?: number;
   childCount?: number;
@@ -316,6 +341,8 @@ export function transformToTopologyData(
         airVolume: a.airVolume,
         windrectId: a.windrectId,
         childCount: areaSensorCount[pid] || 0,
+        level: a.level,
+        levelText: levelTextMap[a.level] || '',
       };
     }),
     // 数据布点节点

+ 360 - 0
tests/useTopologyLayout.spec.ts

@@ -0,0 +1,360 @@
+import {
+  buildVentilationPaths,
+  extractDistrictKeys,
+  formatAirVolumeLabel,
+  transformToTopologyData,
+  LAYOUT,
+} from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout';
+
+/** 构造测风点位(MineArea) */
+const area = (id: string, level: number, airVolume = 100, extra: Record<string, any> = {}) => ({
+  id,
+  name: id,
+  level,
+  airVolume,
+  mineCode: 'M1',
+  ...extra,
+});
+
+/** 构造一个完整的通风回路拓扑(单行示意图数据) */
+function schematicTopo(): TopologyData {
+  const nodes = [
+    { id: 'org:m1', 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 },
+    { id: 'point:c1', name: '采区用风1', category: 1, level: 3, airVolume: 70 },
+    { id: 'point:d1', name: '采区回风1', category: 1, level: 4, airVolume: 60 },
+    { id: 'point:e1', name: '矿井回风1', category: 1, level: 5, airVolume: 50 },
+    { id: 'device:w1', name: '传感器1', category: 2, status: 'normal' },
+    { 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: '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: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('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']);
+  });
+
+  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('起止边取首个/末个存在层级:无 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 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('回风侧 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);
+    }
+  });
+});
+
+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('formatAirVolumeLabel 风量标注', () => {
+  test('有风量标注 m³/min,无风量返回空串', () => {
+    expect(formatAirVolumeLabel(1200)).toBe('1200m³/min');
+    expect(formatAirVolumeLabel(0)).toBe('0m³/min');
+    expect(formatAirVolumeLabel(undefined)).toBe('');
+    expect(formatAirVolumeLabel(null as any)).toBe('');
+  });
+});
+
+describe('computeLayout 从左到右列式(线性)布局', () => {
+  // 列式布局几何参数:schematicTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
+  const centerY = (700 - 90) / 2; // 305
+  const MARGIN = 60;
+  const colGap = 300;
+
+  test('列式坐标:矿井最左,L1→L2→L3→L4→L5 各列从左到右 x 递增,同层同列', () => {
+    const layout = computeLayout(schematicTopo(), 1200, 700);
+    expect(layout.mode).toBe('linear');
+    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);
+  });
+
+  test('同层点位列内上下对称等距:间距 = min(该层级配置上限, 可用高度均分),以 centerY 居中', () => {
+    const layout = computeLayout(schematicTopo(), 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('末端小点:所有点位可见(hiddenNodeIds 为空),无隐藏锚点', () => {
+    const layout = computeLayout(schematicTopo(), 1200, 700);
+    expect(layout.hiddenNodeIds.size).toBe(0);
+  });
+
+  test('数据布点定位:已绑定 → 点位正下方(node-side),未绑定 → 图下方', () => {
+    const layout = computeLayout(schematicTopo(), 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);
+  });
+
+  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' });
+    const layout = computeLayout(data, 1200, 700);
+    expect(layout.mode).toBe('linear');
+    expect(layout.positions['point:u1'].x).toBe(layout.positions['point:e1'].x + colGap);
+  });
+
+  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('无 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');
+    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);
+  });
+
+  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();
+  });
+});
+
+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;
+    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);
+  });
+});