houzekong 3 недель назад
Родитель
Сommit
555a85ad52

+ 3 - 3
src/components/Configurable/preset/IdentifyResult.vue

@@ -12,7 +12,7 @@
       <div class="basis-list flex flex-col gap-1">
         <div v-for="(item, idx) in basisItems" :key="'basis-' + idx" class="basis-item flex justify-between items-center px-2.5 py-1.5">
           <span class="basis-label">{{ item.label }}</span>
-          <span class="basis-value font-semibold" :class="item.colorClass">{{ item.value }}</span>
+          <span class="basis-value font-semibold">{{ item.value }}</span>
         </div>
       </div>
     </div>
@@ -119,11 +119,11 @@
 
       .basis-label {
         font-size: 16px;
-        color: #6b7280;
+        color: @text-color;
       }
       .basis-value {
         font-size: 24px;
-        color: #d97706;
+        color: @text-color;
       }
     }
 

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

@@ -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';

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

@@ -0,0 +1,545 @@
+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,
+  nodeDetailFields,
+  weightKey,
+  transformToTopologyData,
+  collectOrgSubtree,
+  LAYOUT_WIDTH,
+  LAYOUT_HEIGHT,
+} from '../windTopology.data';
+import type { TopologyData, TopoNodeData, OrgPathNode } 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)逐层分组为多行,测风点位/数据布点各占一行
+    const rows: { list: any[]; weight?: string }[] = [];
+    const depthGroups: 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 (!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] });
+    if (catGroups[1]) rows.push({ list: catGroups[1], 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 查询
+      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);
+      }
+
+      const apiData = await getTopologyData(queryDeptId ? { deptId: queryDeptId } : {});
+      renderTopology(transformToTopologyData(orgNodes, apiData.mineAreaList, apiData.windrectList));
+      // 数据为空时给出诊断信息(部门树仍会渲染)
+      if (!apiData.mineAreaList.length && !apiData.windrectList.length) {
+        console.warn('拓扑数据为空:未获取到测风点位/数据布点(deptId=' + (queryDeptId || mineStore.getRootId || '') + ')');
+      }
+    } catch (e) {
+      console.error('拓扑数据加载失败:', e);
+      message.error('拓扑数据加载失败,请查看控制台日志');
+    }
+  }
+
+  /** 操作栏部门选择:过滤部门树(该部门及后代)并按所选部门查询数据。
+   *  注意:v-model 已先行更新 selectedDeptId,此处只需直接触发加载(不能按值去抖,否则恒等跳过) */
+  function setSelectedDept(id: string) {
+    selectedDeptId.value = 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 statusColor = statusColorMap[n.status || 'normal'];
+      const isMarked = n.id === markedSourceId.value;
+      const itemStyle: any = { color: statusColor || cat.color };
+      // 待绑定源节点:放大 + 红色描边,让用户明确当前正在操作哪个节点
+      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,
+  };
+}

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

@@ -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 {

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

@@ -0,0 +1,242 @@
+<!-- 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="true"
+            @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 v-for="cat in categories" :key="cat.name" class="legend-item">
+            <span class="legend-dot" :style="{ background: cat.color }"></span>
+            {{ cat.name }}
+          </span>
+          <span class="legend-item">
+            <span class="legend-dot" style="background: #fa8c16"></span>
+            异常
+          </span>
+          <span class="legend-item">
+            <span class="legend-dot" style="background: #999"></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: categories[selectedNode.category]?.color }">
+          {{ 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 } from './windTopology.data';
+  import { useTopology } from './hooks/useTopology';
+
+  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;
+      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>

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

@@ -0,0 +1,46 @@
+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 });

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

@@ -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] || '',
       };
     }),
     // 数据布点节点

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

@@ -0,0 +1,337 @@
+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', // 离线
+};
+
+// ==================== 三层布局参数 ====================
+
+/** 每类节点用于排序的权重字段(值越大越靠左):测风点位按已绑定数据布点数排序 */
+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', 'arrow'],
+        edgeSymbolSize: [0, 10],
+        label: {
+          show: true,
+          position: 'bottom',
+          fontSize: 11,
+          formatter: (p: any) => p.name || '',
+        },
+        lineStyle: { color: 'source', curveness: 0.3, 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: '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;
+  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,
+      };
+    }),
+    // 数据布点节点
+    ...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 };
+}

+ 1 - 1
src/views/dashboard/HiddenSurface/configurable.data.ts

@@ -98,7 +98,7 @@ export const testConfigProvinceMonitor: Config[] = [
           columns: [
             { name: '执法处', prop: 'name' },
             { name: '矿井数', prop: 'mineNum' },
-            { name: '风量异常', prop: 'exceptionNum' },
+            // { name: '风量异常', prop: 'exceptionNum' },
             { name: '疑似隐蔽工作面', prop: 'suspectedNum' },
           ],
         },

+ 13 - 13
src/views/monitor/sensorMonitor/sensorMonitor.data.ts

@@ -110,19 +110,19 @@ export const historyColumns: BasicColumn[] = [
 
 /** 实时监测查询表单(getWindrectData:deptId 必填) */
 export const schemas: FormSchema[] = [
-  {
-    label: '测风装置',
-    field: 'windrectId',
-    component: 'ApiSelect',
-    colProps: { span: 6 },
-    componentProps: {
-      api: getWindrectList,
-      labelField: 'devicePos',
-      valueField: 'id',
-      placeholder: '请选择测风装置',
-      params: {},
-    },
-  },
+  // {
+  //   label: '测风装置',
+  //   field: 'windrectId',
+  //   component: 'ApiSelect',
+  //   colProps: { span: 6 },
+  //   componentProps: {
+  //     api: getWindrectList,
+  //     labelField: 'devicePos',
+  //     valueField: 'id',
+  //     placeholder: '请选择测风装置',
+  //     params: {},
+  //   },
+  // },
   {
     label: '煤矿状态',
     field: 'gjMineStatus',