Преглед на файлове

[Wip 0000] 拓扑及项目修改建议修改

houzekong преди 2 дни
родител
ревизия
b66bf0813d
променени са 29 файла, в които са добавени 893 реда и са изтрити 337 реда
  1. 24 24
      public/js/global.js
  2. 2 2
      src/components/Configurable/preset/IdentifyResult.vue
  3. 9 3
      src/views/analysis/warningAnalysis/globalAlarm/globalAlarm.data.ts
  4. 29 8
      src/views/analysis/warningAnalysis/globalAlarm/index.vue
  5. 58 0
      src/views/analysis/warningAnalysis/windPointManage/goafManage/goafManage.data.ts
  6. 149 0
      src/views/analysis/warningAnalysis/windPointManage/goafManage/index.vue
  7. 17 2
      src/views/analysis/warningAnalysis/windPointManage/index.vue
  8. 26 0
      src/views/analysis/warningAnalysis/windPointManage/windPointManage.api.ts
  9. 21 3
      src/views/analysis/warningAnalysis/windPointManage/windPointManage.data.ts
  10. 95 9
      src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts
  11. 23 8
      src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts
  12. 5 5
      src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyOrdering.ts
  13. 26 6
      src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue
  14. 7 5
      src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.api.ts
  15. 29 16
      src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts
  16. 135 129
      src/views/dashboard/HiddenSurface/configurable.data.ts
  17. 1 1
      src/views/dashboard/HiddenSurface/index.vue
  18. 1 1
      src/views/dashboard/SealedGoaf/index.vue
  19. 2 0
      src/views/dashboard/basicInfo/minesInfo/index.vue
  20. 3 3
      src/views/monitor/sensorMonitor/index.vue
  21. 2 2
      src/views/monitor/sensorMonitor/sensorMonitor.data.ts
  22. 20 6
      src/views/monitor/surfaceMonitor/index.vue
  23. 14 4
      src/views/monitor/surfaceMonitor/surfaceMonitor.data.ts
  24. 2 0
      src/views/system/cadFile/app.ts
  25. 74 74
      src/views/system/cadFile/components/GoafPopup.vue
  26. 0 1
      src/views/system/cadFile/components/MapView.vue
  27. 11 0
      src/views/system/cadFile/marker.less
  28. 76 16
      tests/useTopologyLayout.spec.ts
  29. 32 9
      tests/useTopologyOrdering.spec.ts

+ 24 - 24
public/js/global.js

@@ -132,30 +132,30 @@ const __STATIC_ROUTES__ = [
         },
         },
         name: 'basicinfo-mines-info',
         name: 'basicinfo-mines-info',
       },
       },
-      {
-        path: '/basicinfo/access-statistics',
-        component: 'dashboard/basicInfo/accessStatistics',
-        meta: {
-          keepAlive: true,
-          internalOrExternal: false,
-          icon: '',
-          componentName: 'index',
-          title: '接入统计',
-        },
-        name: 'basicinfo-access-statistics',
-      },
-      {
-        path: '/basicinfo/closed-statistics',
-        component: 'dashboard/basicInfo/closedStatistics',
-        meta: {
-          keepAlive: true,
-          internalOrExternal: false,
-          icon: '',
-          componentName: 'index',
-          title: '密闭统计',
-        },
-        name: 'basicinfo-closed-statistics',
-      },
+      // {
+      //   path: '/basicinfo/access-statistics',
+      //   component: 'dashboard/basicInfo/accessStatistics',
+      //   meta: {
+      //     keepAlive: true,
+      //     internalOrExternal: false,
+      //     icon: '',
+      //     componentName: 'index',
+      //     title: '接入统计',
+      //   },
+      //   name: 'basicinfo-access-statistics',
+      // },
+      // {
+      //   path: '/basicinfo/closed-statistics',
+      //   component: 'dashboard/basicInfo/closedStatistics',
+      //   meta: {
+      //     keepAlive: true,
+      //     internalOrExternal: false,
+      //     icon: '',
+      //     componentName: 'index',
+      //     title: '密闭统计',
+      //   },
+      //   name: 'basicinfo-closed-statistics',
+      // },
       // {
       // {
       //   path: '/basicinfo/data-quality',
       //   path: '/basicinfo/data-quality',
       //   component: 'dashboard/basicInfo/dataQuality',
       //   component: 'dashboard/basicInfo/dataQuality',

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

@@ -32,6 +32,7 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
   import { computed } from 'vue';
   import { computed } from 'vue';
+  import { getFormattedText } from '../hooks/helper';
 
 
   const props = withDefaults(
   const props = withDefaults(
     defineProps<{
     defineProps<{
@@ -45,8 +46,7 @@
   );
   );
 
 
   const conclusionText = computed(() => {
   const conclusionText = computed(() => {
-    const raw = props.data?.conclusion ?? props.config?.conclusion ?? '-';
-    return raw;
+    return getFormattedText(props.data, props.config.conclusion);
   });
   });
 
 
   const conclusionClass = computed(() => {
   const conclusionClass = computed(() => {

+ 9 - 3
src/views/analysis/warningAnalysis/globalAlarm/globalAlarm.data.ts

@@ -5,9 +5,10 @@ import dayjs from 'dayjs';
 
 
 /** 顶部统计卡片 */
 /** 顶部统计卡片 */
 export const boardData = [
 export const boardData = [
-  { label: '风速超限报警', value: '-' },
-  { label: '风量不足报警', value: '-' },
-  { label: '今日已处理报警', value: '-' },
+  { label: '风速超限报警', value: '-', type: 'vaOverLimit' },
+  { label: '风量不足报警', value: '-', type: 'm3Lack' },
+  { label: '风速波动报警', value: '-', type: 'vaChange' },
+  { label: '断线', value: '-', type: 'disconnect' },
 ];
 ];
 
 
 /** 报警类型映射(AlarmController alarmType 枚举) */
 /** 报警类型映射(AlarmController alarmType 枚举) */
@@ -47,6 +48,11 @@ export const columns: BasicColumn[] = [
     dataIndex: 'expectVal',
     dataIndex: 'expectVal',
     width: 100,
     width: 100,
   },
   },
+  {
+    title: '单位',
+    dataIndex: 'unit',
+    width: 100,
+  },
   // {
   // {
   //   title: '风速波动',
   //   title: '风速波动',
   //   dataIndex: 'vaChange',
   //   dataIndex: 'vaChange',

+ 29 - 8
src/views/analysis/warningAnalysis/globalAlarm/index.vue

@@ -11,6 +11,7 @@
         :value="item.value"
         :value="item.value"
         layout="label-top"
         layout="label-top"
         class="board-item"
         class="board-item"
+        @click.capture="boardClickHandler(item.type)"
       />
       />
     </div>
     </div>
 
 
@@ -29,9 +30,9 @@
               </button>
               </button>
             </div>
             </div>
           </template> -->
           </template> -->
-          <template #resetBefore>
+          <!-- <template #resetBefore>
             <a-button type="default" class="ml-8px" preIcon="mdi:download" @click="onExportXlsTime"> 导出 </a-button>
             <a-button type="default" class="ml-8px" preIcon="mdi:download" @click="onExportXlsTime"> 导出 </a-button>
-          </template>
+          </template> -->
           <template #empty>
           <template #empty>
             <span class="empty-placeholder">-</span>
             <span class="empty-placeholder">-</span>
           </template>
           </template>
@@ -47,9 +48,9 @@
               </button>
               </button>
             </div>
             </div>
           </template> -->
           </template> -->
-          <template #resetBefore>
+          <!-- <template #resetBefore>
             <a-button type="default" class="ml-8px" preIcon="mdi:download" @click="onExportXls"> 导出 </a-button>
             <a-button type="default" class="ml-8px" preIcon="mdi:download" @click="onExportXls"> 导出 </a-button>
-          </template>
+          </template> -->
         </BasicTable>
         </BasicTable>
       </TabPane>
       </TabPane>
 
 
@@ -114,9 +115,11 @@
   import { getAlarmList, getAlarmHistory, getAlarmTrend } from './globalAlarm.api';
   import { getAlarmList, getAlarmHistory, getAlarmTrend } from './globalAlarm.api';
   import { getCurrentStatistics } from '/@/api/workingface';
   import { getCurrentStatistics } from '/@/api/workingface';
   import { useMineDepartmentStore } from '/@/store/modules/mine';
   import { useMineDepartmentStore } from '/@/store/modules/mine';
+  import { useRoute } from 'vue-router';
 
 
   // 激活的Tab页签
   // 激活的Tab页签
   const activeTab = ref('realtime');
   const activeTab = ref('realtime');
+  const route = useRoute();
   const mineStore = useMineDepartmentStore();
   const mineStore = useMineDepartmentStore();
 
 
   // 卡片数据
   // 卡片数据
@@ -127,12 +130,15 @@
     return Promise.all([loadBoardData(params), getAlarmList(params)]).then(([__, res]) => res);
     return Promise.all([loadBoardData(params), getAlarmList(params)]).then(([__, res]) => res);
   };
   };
 
 
-  const { tableContext: ctxRealtime, onExportXls: onExportXlsTime } = useListPage({
+  const { tableContext: ctxRealtime } = useListPage({
     tableProps: {
     tableProps: {
       immediate: false,
       immediate: false,
       columns,
       columns,
       api: wrappedGetMineData,
       api: wrappedGetMineData,
       formConfig: {
       formConfig: {
+        model: {
+          alarmType: route.query?.alarmType,
+        },
         autoSearch: true,
         autoSearch: true,
         labelWidth: 120,
         labelWidth: 120,
         schemas,
         schemas,
@@ -160,12 +166,12 @@
       params: {},
       params: {},
     },
     },
   });
   });
-  const [registerRealtimeTable] = ctxRealtime;
+  const [registerRealtimeTable, { getForm }] = ctxRealtime;
 
 
   // ====== 历史数据表格(getAlarmRecord)======
   // ====== 历史数据表格(getAlarmRecord)======
   const { hiscode } = useInitForm();
   const { hiscode } = useInitForm();
 
 
-  const { tableContext, onExportXls } = useListPage({
+  const { tableContext } = useListPage({
     tableProps: {
     tableProps: {
       immediate: false,
       immediate: false,
       columns: historyColumns,
       columns: historyColumns,
@@ -205,7 +211,7 @@
       name: '报警全览历史数据',
       name: '报警全览历史数据',
     },
     },
   });
   });
-  const [registerHistoryTable] = tableContext;
+  const [registerHistoryTable, { getForm: getHistoryForm }] = tableContext;
 
 
   // ====== 加载卡片数据(getCurrentStatistics)======
   // ====== 加载卡片数据(getCurrentStatistics)======
   async function loadBoardData(params?: any) {
   async function loadBoardData(params?: any) {
@@ -213,6 +219,8 @@
       const result = await getCurrentStatistics({ deptId: params?.deptId });
       const result = await getCurrentStatistics({ deptId: params?.deptId });
       boardDataRef.value[0].value = result.vaOverLimitNum ?? '-';
       boardDataRef.value[0].value = result.vaOverLimitNum ?? '-';
       boardDataRef.value[1].value = result.m3LackNum ?? '-';
       boardDataRef.value[1].value = result.m3LackNum ?? '-';
+      boardDataRef.value[2].value = result.vaChangeNum ?? '-';
+      boardDataRef.value[3].value = result.disconnectNum ?? '-';
       // 第4卡(今日已处理报警)暂不接入,保持 '-'
       // 第4卡(今日已处理报警)暂不接入,保持 '-'
     } catch {
     } catch {
       // 使用默认值
       // 使用默认值
@@ -272,6 +280,19 @@
     chartInstance?.resize();
     chartInstance?.resize();
   }
   }
 
 
+  async function boardClickHandler(type) {
+    const inst1 = getForm();
+    const inst2 = getHistoryForm();
+    if (inst1) {
+      await getForm()?.setFieldsValue({ alarmType: type });
+      getForm().submit();
+    }
+    if (inst2) {
+      await getHistoryForm()?.setFieldsValue({ alarmType: type });
+      getHistoryForm().submit();
+    }
+  }
+
   onMounted(() => {
   onMounted(() => {
     loadBoardData();
     loadBoardData();
     nextTick(() => renderChart());
     nextTick(() => renderChart());

+ 58 - 0
src/views/analysis/warningAnalysis/windPointManage/goafManage/goafManage.data.ts

@@ -0,0 +1,58 @@
+import type { BasicColumn } from '/@/components/Table';
+import type { FormSchema } from '/@/components/Table';
+import { h } from 'vue';
+import { renderMineNameByFax } from '../windPointManage.data';
+
+/** 供风区域(采空区/Goaf)管理表格列 */
+export const goafColumns: BasicColumn[] = [
+  {
+    title: '煤矿编号',
+    dataIndex: 'mineCode',
+    width: 200,
+    fixed: 'left',
+    customRender: ({ text }) => h('span', renderMineNameByFax(String(text ?? ''))),
+  },
+  {
+    title: '区域名称',
+    dataIndex: 'name',
+    width: 200,
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    width: 160,
+  },
+];
+
+/** 新增/编辑供风区域(Goaf)表单 */
+export const goafFormSchema: FormSchema[] = [
+  {
+    label: 'id',
+    field: 'id',
+    component: 'Input',
+    show: false,
+  },
+  {
+    label: '煤矿编号',
+    field: 'mineCode',
+    component: 'MineCascader',
+    required: true,
+    // 编辑态(已有 id)禁用矿编码,避免误改归属矿井;新增态保持可选
+    dynamicDisabled: ({ values }) => !!values.id,
+    componentProps: {
+      placeholder: '请输入煤矿编号',
+      changeOnSelect: true,
+      initFromStore: false,
+      syncFromStore: false,
+    },
+  },
+  {
+    label: '区域名称',
+    field: 'name',
+    required: true,
+    component: 'Input',
+    componentProps: {
+      placeholder: '请输入区域名称',
+    },
+  },
+];

+ 149 - 0
src/views/analysis/warningAnalysis/windPointManage/goafManage/index.vue

@@ -0,0 +1,149 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<template>
+  <div class="goaf-manage">
+    <BasicTable @register="registerTable" :scroll="{ x: 'max-content', y: 420 }" :style="{ padding: 0 }">
+      <template #action="{ record }">
+        <button @click="handleEdit(record)" class="action-btn" title="编辑">
+          <SvgIcon name="edit" />
+        </button>
+        <a-popconfirm title="确认删除?" @confirm="handleDelete(record)">
+          <button class="action-btn" title="删除">
+            <SvgIcon name="delete" />
+          </button>
+        </a-popconfirm>
+      </template>
+      <template #resetBefore>
+        <a-button class="ml-2" preIcon="ant-design:plus-outlined" @click="openAddModal"> 新增 </a-button>
+      </template>
+    </BasicTable>
+
+    <!-- 新增/编辑供风区域弹窗 -->
+    <BasicModal @register="registerModal" :width="800" :minHeight="200" centered :title="modalTitle" @ok="handleSubmit" destroyOnClose>
+      <BasicForm @register="registerForm" />
+    </BasicModal>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed } from 'vue';
+  import { BasicTable } from '/@/components/Table';
+  import { BasicForm, useForm } from '/@/components/Form';
+  import { message } from 'ant-design-vue';
+  import { SvgIcon } from '/@/components/Icon';
+  import { BasicModal } from '/@/components/Modal/index';
+  import { useModal } from '/@/components/Modal';
+  import { useListPage } from '/@/hooks/system/useListPage';
+  import { goafColumns, goafFormSchema } from './goafManage.data';
+  import { getGoafList, saveGoaf, deleteGoaf } from '../windPointManage.api';
+  import { useMineDepartmentStore } from '/@/store/modules/mine';
+
+  // ====== 供风区域管理表格 ======
+  const { tableContext } = useListPage({
+    tableProps: {
+      title: '供风区域管理',
+      formConfig: {
+        autoSearch: true,
+        labelWidth: 120,
+        schemas: [
+          {
+            label: '煤矿名称',
+            field: 'deptId',
+            component: 'MineCascader',
+            // colProps: { span: 10 },
+          },
+        ],
+        showAdvancedButton: false,
+        schemaGroupNames: ['供风区域查询'],
+        actionColOptions: {
+          lg: 14,
+          xl: 12,
+          xxl: 10,
+        },
+      },
+      useSearchForm: true,
+      immediate: true,
+      columns: goafColumns,
+      api: getGoafList,
+      pagination: true,
+      striped: true,
+      bordered: true,
+      showIndexColumn: true,
+      actionColumn: {
+        width: 180,
+        title: '操作',
+        dataIndex: 'action',
+        slots: { customRender: 'action' },
+      },
+    },
+  });
+  const [registerTable, goafTable] = tableContext;
+
+  // 组织树 store(fax↔部门 id 换算)
+  const mineStore = useMineDepartmentStore();
+
+  // ====== 新增/编辑供风区域弹窗 ======
+  const isUpdate = ref(true);
+  const modalTitle = computed(() => (!isUpdate.value ? '新增供风区域' : '编辑供风区域'));
+
+  const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
+    schemas: goafFormSchema,
+    showActionButtonGroup: false,
+    labelCol: {
+      xs: { span: 24 },
+      sm: { span: 4 },
+    },
+    wrapperCol: {
+      xs: { span: 24 },
+      sm: { span: 18 },
+    },
+  });
+
+  const [registerModal, { openModal, closeModal, setModalProps }] = useModal();
+
+  function openAddModal() {
+    isUpdate.value = false;
+    openModal(true, {});
+    return resetFields();
+  }
+
+  async function handleEdit(record: any) {
+    isUpdate.value = true;
+    openModal(true, {});
+    await resetFields();
+    // 回显:record.mineCode 为矿编码(fax),换算为部门 id 供 MineCascader 正确显示
+    const mine = mineStore.findDepart((n) => n.fax === record.mineCode, mineStore.getDepartTree);
+    return setFieldsValue({ ...record, mineCode: mine?.id ?? record.mineCode });
+  }
+
+  async function handleDelete(record: any) {
+    try {
+      await deleteGoaf({ ids: record.id });
+      goafTable?.reload();
+    } catch {
+      message.error('删除失败');
+    }
+  }
+
+  async function handleSubmit() {
+    try {
+      const values = await validate();
+      setModalProps({ confirmLoading: true });
+      await saveGoaf(values);
+      closeModal();
+      goafTable?.reload();
+    } catch {
+      // validate 未通过时不处理
+    } finally {
+      setModalProps({ confirmLoading: false });
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .goaf-manage {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+</style>

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

@@ -19,7 +19,7 @@
               </template>
               </template>
               <template #resetBefore>
               <template #resetBefore>
                 <a-button class="ml-2" preIcon="ant-design:plus-outlined" @click="openAddModal"> 新增 </a-button>
                 <a-button class="ml-2" preIcon="ant-design:plus-outlined" @click="openAddModal"> 新增 </a-button>
-                <a-button class="ml-2" preIcon="ant-design:import-outlined"> 导入 </a-button>
+                <!-- <a-button class="ml-2" preIcon="ant-design:import-outlined"> 导入 </a-button> -->
               </template>
               </template>
             </BasicTable>
             </BasicTable>
           </a-col>
           </a-col>
@@ -48,6 +48,11 @@
       </TabPane>
       </TabPane>
       -->
       -->
 
 
+      <!-- 供风区域管理 -->
+      <TabPane tab="供风区域管理" key="goafManage">
+        <GoafManage />
+      </TabPane>
+
       <!-- 测风网络拓扑 -->
       <!-- 测风网络拓扑 -->
       <TabPane tab="测风网络拓扑" key="windTopology">
       <TabPane tab="测风网络拓扑" key="windTopology">
         <WindTopology />
         <WindTopology />
@@ -93,6 +98,7 @@
     saveModelConfigs, */
     saveModelConfigs, */
   } from './windPointManage.api';
   } from './windPointManage.api';
   import WindTopology from './windTopology/index.vue';
   import WindTopology from './windTopology/index.vue';
+  import GoafManage from './goafManage/index.vue';
   import { useMineDepartmentStore } from '/@/store/modules/mine';
   import { useMineDepartmentStore } from '/@/store/modules/mine';
 
 
   // 激活的Tab页签
   // 激活的Tab页签
@@ -119,11 +125,13 @@
             colProps: { span: 10 },
             colProps: { span: 10 },
             componentProps: {
             componentProps: {
               options: [
               options: [
+                { label: '进风井', value: 0 },
                 { label: '矿井进风', value: 1 },
                 { label: '矿井进风', value: 1 },
                 { label: '采区进风', value: 2 },
                 { label: '采区进风', value: 2 },
                 { label: '采区用风', value: 3 },
                 { label: '采区用风', value: 3 },
                 { label: '采区回风', value: 4 },
                 { label: '采区回风', value: 4 },
                 { label: '矿井回风', value: 5 },
                 { label: '矿井回风', value: 5 },
+                { label: '回风井', value: 6 },
               ],
               ],
             },
             },
           },
           },
@@ -300,7 +308,14 @@
     await resetFields();
     await resetFields();
     // 回显:record.mineCode 为矿编码(fax),换算为部门 id 供 MineCascader 正确显示
     // 回显:record.mineCode 为矿编码(fax),换算为部门 id 供 MineCascader 正确显示
     const mine = mineStore.findDepart((n) => n.fax === record.mineCode, mineStore.getDepartTree);
     const mine = mineStore.findDepart((n) => n.fax === record.mineCode, mineStore.getDepartTree);
-    return setFieldsValue({ ...record, mineCode: mine?.id ?? record.mineCode });
+    return setFieldsValue({
+      ...record,
+      mineCode: mine?.id ?? record.mineCode,
+      // 回显:供风区域后端返回对象数组 [{ id }],表单 ApiSelect 多选需要 id 数组
+      goafList: Array.isArray(record.goafList)
+        ? record.goafList.map((g: any) => (typeof g === 'object' && g !== null ? g.id : g))
+        : [],
+    });
   }
   }
 
 
   // ====== 判识模型参数编辑弹窗(暂注释,待接口就绪后恢复)======
   // ====== 判识模型参数编辑弹窗(暂注释,待接口就绪后恢复)======

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

@@ -6,6 +6,10 @@ enum Api {
   addMineArea = '/workingface/mineArea/addMineArea',
   addMineArea = '/workingface/mineArea/addMineArea',
   updateMineArea = '/workingface/mineArea/updateMineArea',
   updateMineArea = '/workingface/mineArea/updateMineArea',
   deleteMineArea = '/workingface/mineArea/deleteMineArea',
   deleteMineArea = '/workingface/mineArea/deleteMineArea',
+  getGoafList = '/workingface/mineArea/getGoafList',
+  addGoaf = '/workingface/mineArea/addGoaf',
+  updateGoaf = '/workingface/mineArea/updateGoaf',
+  deleteGoaf = '/workingface/mineArea/deleteGoaf',
   getWindrectList = '/workingface/windrect/getWindrectList',
   getWindrectList = '/workingface/windrect/getWindrectList',
   getWindrectRegulation = '/workingface/mineData/getWindrectRegulation',
   getWindrectRegulation = '/workingface/mineData/getWindrectRegulation',
   getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
   getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
@@ -36,12 +40,34 @@ export const savePoint = (params?: any) => {
     const mine = useMineDepartmentStore().findDepartById(payload.mineCode);
     const mine = useMineDepartmentStore().findDepartById(payload.mineCode);
     if (mine?.fax) payload.mineCode = mine.fax;
     if (mine?.fax) payload.mineCode = mine.fax;
   }
   }
+  // 供风区域:多选 id 数组 → 后端约定的对象数组 [{ id }]
+  if (Array.isArray(payload.goafList)) {
+    payload.goafList = payload.goafList.map((id: string) => ({ id }));
+  }
   return defHttp.post({ url, params: payload });
   return defHttp.post({ url, params: payload });
 };
 };
 
 
 /** 删除 MineArea(含关联关系) */
 /** 删除 MineArea(含关联关系) */
 export const deletePoint = (params?: any) => defHttp.post({ url: Api.deleteMineArea, params }, { joinParamsToUrl: true });
 export const deletePoint = (params?: any) => defHttp.post({ url: Api.deleteMineArea, params }, { joinParamsToUrl: true });
 
 
+/** 根据部门ID查询该部门下所有煤矿的供风区域(采空区)列表(GoafController.getGoafList) */
+export const getGoafList = (params?: any) => defHttp.post({ url: Api.getGoafList, params }, { joinParamsToUrl: true });
+
+/** 新增或编辑供风区域(Goaf,根据是否有 id 自动切换) */
+export const saveGoaf = (params?: any) => {
+  const url = params?.id ? Api.updateGoaf : Api.addGoaf;
+  const payload = { ...params };
+  // 表单 mineCode 为 MineCascader 的部门 id,需换算为 API 需要的矿编码(fax)
+  if (payload.mineCode) {
+    const mine = useMineDepartmentStore().findDepartById(payload.mineCode);
+    if (mine?.fax) payload.mineCode = mine.fax;
+  }
+  return defHttp.post({ url, params: payload });
+};
+
+/** 删除供风区域(Goaf,ids 必填,逗号分隔) */
+export const deleteGoaf = (params?: any) => defHttp.post({ url: Api.deleteGoaf, params }, { joinParamsToUrl: true });
+
 /** 根据矿ID查询下面所有测风装置信息(WindrectController.getWindrectList) */
 /** 根据矿ID查询下面所有测风装置信息(WindrectController.getWindrectList) */
 export const getWindrectList = (params?: any) => defHttp.post({ url: Api.getWindrectList, params }, { joinParamsToUrl: true });
 export const getWindrectList = (params?: any) => defHttp.post({ url: Api.getWindrectList, params }, { joinParamsToUrl: true });
 
 

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

@@ -3,10 +3,10 @@ import type { FormSchema } from '/@/components/Table';
 import { h } from 'vue';
 import { h } from 'vue';
 import { StatusColorEnum } from '/@/enums/jeecgEnum';
 import { StatusColorEnum } from '/@/enums/jeecgEnum';
 import { useMineDepartmentStore } from '/@/store/modules/mine';
 import { useMineDepartmentStore } from '/@/store/modules/mine';
-import { getWindrectList, getWindrectRegulation } from './windPointManage.api';
+import { getWindrectList, getWindrectRegulation, getGoafList } from './windPointManage.api';
 
 
 /** 按矿编码(fax)在组织树中匹配部门名称,未匹配时回退显示原编码 */
 /** 按矿编码(fax)在组织树中匹配部门名称,未匹配时回退显示原编码 */
-function renderMineNameByFax(mineCode: string): string {
+export function renderMineNameByFax(mineCode: string): string {
   if (!mineCode) return '-';
   if (!mineCode) return '-';
   const mine = useMineDepartmentStore().findDepart((n) => n.fax === mineCode, useMineDepartmentStore().getDepartTree);
   const mine = useMineDepartmentStore().findDepart((n) => n.fax === mineCode, useMineDepartmentStore().getDepartTree);
   return mine?.departName || mineCode;
   return mine?.departName || mineCode;
@@ -33,11 +33,13 @@ export const pointColumns: BasicColumn[] = [
     width: 100,
     width: 100,
     customRender: ({ text }) => {
     customRender: ({ text }) => {
       const map: Record<number, string> = {
       const map: Record<number, string> = {
+        0: '进风井',
         1: '矿井进风',
         1: '矿井进风',
         2: '采区进风',
         2: '采区进风',
         3: '采区用风',
         3: '采区用风',
         4: '采区回风',
         4: '采区回风',
         5: '矿井回风',
         5: '矿井回风',
+        6: '回风井',
       };
       };
       return map[text] || text;
       return map[text] || text;
     },
     },
@@ -103,7 +105,7 @@ export const windrectColumns: BasicColumn[] = [
     customRender: ({ text }) => {
     customRender: ({ text }) => {
       const normal = String(text) === '1';
       const normal = String(text) === '1';
       const color = normal ? StatusColorEnum.green : StatusColorEnum.red;
       const color = normal ? StatusColorEnum.green : StatusColorEnum.red;
-      const label = normal ? '正常' : '停用';
+      const label = normal ? '在线' : '离线';
       return h('span', { style: { color, fontWeight: 'bold' } }, label);
       return h('span', { style: { color, fontWeight: 'bold' } }, label);
     },
     },
   },
   },
@@ -157,11 +159,13 @@ export const formSchema: FormSchema[] = [
     componentProps: {
     componentProps: {
       placeholder: '请选择层级',
       placeholder: '请选择层级',
       options: [
       options: [
+        { label: '进风井', value: 0 },
         { label: '矿井进风', value: 1 },
         { label: '矿井进风', value: 1 },
         { label: '采区进风', value: 2 },
         { label: '采区进风', value: 2 },
         { label: '采区用风', value: 3 },
         { label: '采区用风', value: 3 },
         { label: '采区回风', value: 4 },
         { label: '采区回风', value: 4 },
         { label: '矿井回风', value: 5 },
         { label: '矿井回风', value: 5 },
+        { label: '回风井', value: 6 },
       ],
       ],
     },
     },
   },
   },
@@ -202,6 +206,20 @@ export const formSchema: FormSchema[] = [
       params: formModel?.mineCode ? { deptId: formModel.mineCode } : {},
       params: formModel?.mineCode ? { deptId: formModel.mineCode } : {},
     }),
     }),
   },
   },
+  {
+    label: '供风区域',
+    field: 'goafList',
+    component: 'ApiSelect',
+    // 与「煤矿编号」联动:按选中矿(MineCascader 值为部门 id)过滤本矿供风区域(采空区),可多选;
+    // 未选矿时返回空列表,避免 getGoafList 缺少必填 deptId 的无效请求;切矿后自动重新加载
+    componentProps: ({ formModel }) => ({
+      api: () => (formModel?.mineCode ? getGoafList({ deptId: formModel.mineCode }) : Promise.resolve([])),
+      labelField: 'name',
+      valueField: 'id',
+      mode: 'multiple',
+      placeholder: '请选择供风区域(可多选)',
+    }),
+  },
 ];
 ];
 
 
 /* ====== 判识模型管理(暂注释,待接口就绪后恢复)======
 /* ====== 判识模型管理(暂注释,待接口就绪后恢复)======

+ 95 - 9
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts

@@ -12,10 +12,10 @@ import {
   POINT_SIZE,
   POINT_SIZE,
   JUDGE_COLOR,
   JUDGE_COLOR,
   JUDGE_WIDTH,
   JUDGE_WIDTH,
-  // formatAirVolumeLabel,
+  LAYOUT,
 } from '../windTopology.data';
 } from '../windTopology.data';
 import type { TopologyData, TopoNodeData } from '../windTopology.data';
 import type { TopologyData, TopoNodeData } from '../windTopology.data';
-import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation } from '../windTopology.api';
+import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation, updateMineAreaTopology } from '../windTopology.api';
 import { computeLayout } from './useTopologyLayout';
 import { computeLayout } from './useTopologyLayout';
 
 
 /** 拓扑交互模式:relation=绑定模式(关系编辑)、device=布点模式(设备绑定/解绑) */
 /** 拓扑交互模式:relation=绑定模式(关系编辑)、device=布点模式(设备绑定/解绑) */
@@ -128,7 +128,7 @@ export function useTopology() {
     loadTopology();
     loadTopology();
   }
   }
 
 
-  // —— 渲染拓扑 ——midLabel
+  // —— 渲染拓扑 ——
   function renderTopology(data: TopologyData) {
   function renderTopology(data: TopologyData) {
     topologyData = data;
     topologyData = data;
     if (!chartInstance) return;
     if (!chartInstance) return;
@@ -138,7 +138,7 @@ export function useTopology() {
 
 
     const cw = chartInstance.getWidth();
     const cw = chartInstance.getWidth();
     const ch = chartInstance.getHeight();
     const ch = chartInstance.getHeight();
-    // 列式布局(总进 → lv1..lv5 → 未分级
+    // 列式布局(总进 → lv0..lv6 → 未分级 → 总回
     const layout = computeLayout(data, cw, ch);
     const layout = computeLayout(data, cw, ch);
     const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
     const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
     // roadway 边主要数据点位(名称已在连线中点展示,节点自身不再显示文本标签,避免重复)
     // roadway 边主要数据点位(名称已在连线中点展示,节点自身不再显示文本标签,避免重复)
@@ -188,8 +188,10 @@ export function useTopology() {
       if (pos) {
       if (pos) {
         en.x = pos.x;
         en.x = pos.x;
         en.y = pos.y;
         en.y = pos.y;
-        // 节点固定(固定布局,防止误拖破坏)
-        en.fixed = true;
+        // 地面节点固定不可拖;测风点位可拖动(配合"保存布局"持久化位置)
+        if (n.category === 0) {
+          en.fixed = true;
+        }
       }
       }
       echartsNodes.push(en);
       echartsNodes.push(en);
     }
     }
@@ -205,6 +207,7 @@ export function useTopology() {
           target: l.target,
           target: l.target,
           kind: l.kind,
           kind: l.kind,
           pointId: l.pointId,
           pointId: l.pointId,
+          mainPointId: l.mainPointId,
           relationId: l.relationId,
           relationId: l.relationId,
           flow: l.flow,
           flow: l.flow,
         };
         };
@@ -219,9 +222,7 @@ export function useTopology() {
           const suspected = point?.suspected;
           const suspected = point?.suspected;
           // 常态标注:仅名称;高亮标注:名称 + 圆点与已绑定布点名(未绑定输出浅灰占位圆点)
           // 常态标注:仅名称;高亮标注:名称 + 圆点与已绑定布点名(未绑定输出浅灰占位圆点)
           link.midLabel = point ? `{name|${point.name}}` : '';
           link.midLabel = point ? `{name|${point.name}}` : '';
-          link.midLabelFull = point
-            ? `{name|${point.name}}\n${deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'}`
-            : '';
+          link.midLabelFull = point ? `{name|${point.name}}\n${deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'}` : '';
           link.lineStyle = {
           link.lineStyle = {
             color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,
             color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,
             width: suspected ? JUDGE_WIDTH : 3,
             width: suspected ? JUDGE_WIDTH : 3,
@@ -454,6 +455,90 @@ export function useTopology() {
     });
     });
   }
   }
 
 
+  // ==================== 布局保存 ====================
+
+  /** 获取当前 graph 系列的 SeriesData(ECharts 类型中 getModel 为私有,运行时可用) */
+  function seriesDataOf(): any {
+    if (!chartInstance) return null;
+    const seriesModel = (chartInstance as any).getModel().getSeriesByIndex(0) as any;
+    return seriesModel?.getData?.() || null;
+  }
+
+  /**
+   * 从 ECharts 实例一次性读取全部节点当前渲染坐标(含拖动后位置)。
+   * 坐标来源(按优先级):
+   *   1. SeriesData.getItemLayout(i)——渲染层布局,节点拖动后由 ECharts 写入,
+   *      注意 graph 返回格式为 [x, y] 数组(非 {x, y} 对象);
+   *   2. getRawDataItem(i) 的 x/y(初始 setOption 传入的坐标)。
+   * 节点 id 通过 data.getId(i) 关联(renderTopology 中 en.id = n.id)。
+   */
+  function getChartNodePositions(): Map<string, { x: number; y: number }> {
+    const positions = new Map<string, { x: number; y: number }>();
+    const data = seriesDataOf();
+    if (!data) return positions;
+    const count = data.count();
+    for (let i = 0; i < count; i++) {
+      const id = data.getId?.(i);
+      if (id == null || id === '') continue;
+      let x: number | undefined;
+      let y: number | undefined;
+      // 1) 渲染层布局:graph 拖动后坐标为 [x, y] 数组;兼容对象形式
+      const layout = data.getItemLayout?.(i);
+      if (Array.isArray(layout) && layout.length >= 2) {
+        x = layout[0];
+        y = layout[1];
+      } else if (layout && typeof layout.x === 'number' && typeof layout.y === 'number') {
+        x = layout.x;
+        y = layout.y;
+      }
+      // 2) 回退:原始 data item 的 x/y(初始布局坐标)
+      if (!(Number.isFinite(x) && Number.isFinite(y))) {
+        const raw = data.getRawDataItem?.(i);
+        if (raw && Number.isFinite(raw.x) && Number.isFinite(raw.y)) {
+          x = raw.x;
+          y = raw.y;
+        }
+      }
+      if (Number.isFinite(x) && Number.isFinite(y)) {
+        positions.set(String(id), { x: x as number, y: y as number });
+      }
+    }
+    return positions;
+  }
+
+  /**
+   * 保存当前拓扑布局:将全部测风点位(含 level 0/6)当前位置写入
+   * updateMineAreaTopology 的 topologyX/topologyY 字段(ECharts 画布像素坐标)。
+   * 坐标在点击保存时一次性从 ECharts 实例读取,并按 LAYOUT.snapGrid(对齐像素值)取整。
+   * 返回是否全部保存成功。
+   */
+  async function saveTopologyLayout(): Promise<boolean> {
+    const points = topologyData.nodes.filter((n) => n.category === 1 && n.rawId);
+    if (points.length === 0) {
+      message.warning('当前无可保存的测风点位');
+      return false;
+    }
+    const positions = getChartNodePositions();
+    const grid = LAYOUT.snapGrid || 10;
+    // 仅提交能读到坐标的节点(缺失坐标的节点跳过,避免 Promise/空对象混入请求体);
+    // 保存时按配置的对齐像素值取整(对齐网格)
+    const payload = points
+      .map((p) => ({ p, pos: positions.get(p.id) }))
+      .filter((x): x is { p: TopoNodeData; pos: { x: number; y: number } } => Boolean(x.pos))
+      .map(({ p, pos }) => ({
+        id: p.rawId,
+        topologyX: Math.round(pos.x / grid) * grid,
+        topologyY: Math.round(pos.y / grid) * grid,
+      }));
+    if (payload.length === 0) {
+      message.warning('未能读取节点坐标,布局未保存');
+      return false;
+    }
+    await updateMineAreaTopology(payload);
+    message.success('布局保存完成');
+    return true;
+  }
+
   // ==================== 节点详情 ====================
   // ==================== 节点详情 ====================
 
 
   function showNodeDetail(node: TopoNodeData) {
   function showNodeDetail(node: TopoNodeData) {
@@ -539,6 +624,7 @@ export function useTopology() {
     zoomOut,
     zoomOut,
     resetView,
     resetView,
     clearSelection,
     clearSelection,
+    saveTopologyLayout,
     dispose,
     dispose,
   };
   };
 }
 }

+ 23 - 8
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts

@@ -7,24 +7,25 @@ export interface LayoutResult {
   positions: Record<string, { x: number; y: number }>;
   positions: Record<string, { x: number; y: number }>;
 }
 }
 
 
-/** 第 idx 列(0=总进、1..5=lv1..lv5、6=未分级、7=总回)的 x 坐标 */
+/** 第 idx 列(0=总进、1..7=lv0..lv6、8=未分级、9=总回)的 x 坐标 */
 const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
 const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
 
 
-/** level → 配置键:仅 1..5 视为已分级,0/NaN/越界统一映射为 'ungraded' */
+/** level → 配置键:仅 0..6 视为已分级,NaN/越界统一映射为 'ungraded' */
 const levelKey = (lv?: number | null): number | 'ungraded' =>
 const levelKey = (lv?: number | null): number | 'ungraded' =>
-  lv !== undefined && lv !== null && Number.isFinite(lv) && lv >= 1 && lv <= 5 ? lv : 'ungraded';
+  lv !== undefined && lv !== null && Number.isFinite(lv) && lv >= 0 && lv <= 6 ? lv : 'ungraded';
 
 
 /** 该层级行距:按层级分别配置,未配置的层级回退 rowGapDefault */
 /** 该层级行距:按层级分别配置,未配置的层级回退 rowGapDefault */
 const rowGapFor = (lv?: number | null) => LAYOUT.rowGap[levelKey(lv) as any] ?? LAYOUT.rowGapDefault;
 const rowGapFor = (lv?: number | null) => LAYOUT.rowGap[levelKey(lv) as any] ?? LAYOUT.rowGapDefault;
 
 
 /**
 /**
  * 列式布局(从左到右,固定间距):
  * 列式布局(从左到右,固定间距):
- *   固定列序 总进 → lv1..lv5 → 未分级 → 总回;
+ *   固定列序 总进 → lv0..lv6 → 未分级 → 总回;
  *   列间距(x 轴间隔)固定:x = margin + colGap × 列序;
  *   列间距(x 轴间隔)固定:x = margin + colGap × 列序;
  *   行间距(y 轴间隔)固定且按层级分别配置:每列取该层级 rowGap[level](未配置回退 rowGapDefault);
  *   行间距(y 轴间隔)固定且按层级分别配置:每列取该层级 rowGap[level](未配置回退 rowGapDefault);
  *   每列围绕画布垂直中心独立居中:colStart = centerY − spacing·(n−1)/2,
  *   每列围绕画布垂直中心独立居中:colStart = centerY − spacing·(n−1)/2,
  *   节点 y = colStart + 秩 × spacing;列内顺序由 useTopologyOrdering 交叉最小化排序
  *   节点 y = colStart + 秩 × spacing;列内顺序由 useTopologyOrdering 交叉最小化排序
- *   (median/barycenter 多轮扫描)决定,不再按风量降序。
+ *   (median/barycenter 多轮扫描)决定;
+ *   节点自带有效 topologyX/topologyY(布局保存坐标,像素,非 0/null/'')时直接使用,跳过列布局。
  */
  */
 export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
 export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
   const H = height < 100 ? LAYOUT_HEIGHT : height;
   const H = height < 100 ? LAYOUT_HEIGHT : height;
@@ -40,7 +41,7 @@ export function computeLayout(data: TopologyData, _width: number, height: number
   const ungraded: TopoNodeData[] = [];
   const ungraded: TopoNodeData[] = [];
   for (const p of points) {
   for (const p of points) {
     const lv = Number(p.level);
     const lv = Number(p.level);
-    if (Number.isFinite(lv) && lv >= 1 && lv <= 5) {
+    if (Number.isFinite(lv) && lv >= 0 && lv <= 6) {
       if (!byLevel.has(lv)) byLevel.set(lv, []);
       if (!byLevel.has(lv)) byLevel.set(lv, []);
       byLevel.get(lv)!.push(p);
       byLevel.get(lv)!.push(p);
     } else {
     } else {
@@ -69,10 +70,10 @@ export function computeLayout(data: TopologyData, _width: number, height: number
     col.splice(0, col.length, ...indexed.map((x) => x.n));
     col.splice(0, col.length, ...indexed.map((x) => x.n));
   };
   };
 
 
-  // 列序:总进 → lv1..lv5 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
+  // 列序:总进 → lv0..lv6 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
   const cols: TopoNodeData[][] = [];
   const cols: TopoNodeData[][] = [];
   cols.push(rootIn ? [rootIn] : []);
   cols.push(rootIn ? [rootIn] : []);
-  for (let lv = 1; lv <= 5; lv++) {
+  for (let lv = 0; lv <= 6; lv++) {
     const col = byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : [];
     const col = byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : [];
     applyOrder(col, String(lv));
     applyOrder(col, String(lv));
     cols.push(col);
     cols.push(col);
@@ -94,5 +95,19 @@ export function computeLayout(data: TopologyData, _width: number, height: number
     });
     });
   });
   });
 
 
+  // 布局保存坐标覆盖:仅有效像素坐标生效。
+  // 排除 undefined/null/''/0 等后端默认无效值——否则 Number(null)=0、Number('')=0 会把
+  // 所有节点覆盖到 (0,0) 挤成一团。
+  const isValidSavedCoord = (v: unknown): boolean => {
+    if (v === undefined || v === null || v === '') return false;
+    const n = Number(v);
+    return Number.isFinite(n) && n !== 0;
+  };
+  for (const n of data.nodes) {
+    if (isValidSavedCoord(n.topologyX) && isValidSavedCoord(n.topologyY)) {
+      positions[n.id] = { x: Number(n.topologyX), y: Number(n.topologyY) };
+    }
+  }
+
   return { positions };
   return { positions };
 }
 }

+ 5 - 5
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyOrdering.ts

@@ -26,13 +26,13 @@ export interface OrderingOptions {
   sweepRounds?: number;
   sweepRounds?: number;
 }
 }
 
 
-/** 参与排序的层级 key(1..5),与 levelTextMap / computeLayout 分组一致 */
-const ORDER_KEYS = ['1', '2', '3', '4', '5'];
+/** 参与排序的层级 key(0=进风井 1..5 现有层级 6=回风井),与 levelTextMap / computeLayout 分组一致 */
+const ORDER_KEYS = ['0', '1', '2', '3', '4', '5', '6'];
 
 
-/** 层级 key:1..5 有效 → 数字字符串;否则 'ungraded' */
+/** 层级 key:0..6 有效 → 数字字符串;否则 'ungraded' */
 function levelKeyOf(p: TopoNodeData): string {
 function levelKeyOf(p: TopoNodeData): string {
   const lv = Number(p.level);
   const lv = Number(p.level);
-  return Number.isFinite(lv) && lv >= 1 && lv <= 5 ? String(lv) : 'ungraded';
+  return Number.isFinite(lv) && lv >= 0 && lv <= 6 ? String(lv) : 'ungraded';
 }
 }
 
 
 /**
 /**
@@ -45,7 +45,7 @@ function buildPairEdges(data: TopologyData): Map<string, Array<[string, string]>
   for (const n of data.nodes) {
   for (const n of data.nodes) {
     if (n.category !== 1) continue;
     if (n.category !== 1) continue;
     const lv = Number(n.level);
     const lv = Number(n.level);
-    if (Number.isFinite(lv) && lv >= 1 && lv <= 5) levelById.set(n.id, lv);
+    if (Number.isFinite(lv) && lv >= 0 && lv <= 6) levelById.set(n.id, lv);
   }
   }
 
 
   const edgesByPair = new Map<string, Array<[string, string]>>();
   const edgesByPair = new Map<string, Array<[string, string]>>();

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

@@ -28,10 +28,16 @@
           <a-button :type="mode === 'device' ? 'primary' : 'default'" @click="setMode('device')">布点模式</a-button>
           <a-button :type="mode === 'device' ? 'primary' : 'default'" @click="setMode('device')">布点模式</a-button>
         </a-button-group>
         </a-button-group>
         <a-divider type="vertical" />
         <a-divider type="vertical" />
-        <a-button @click="refreshData" title="刷新">
-          <template #icon><SvgIcon name="refresh" /></template>
-          刷新
-        </a-button>
+        <a-button-group>
+          <a-button :loading="saving" @click="handleSaveLayout" title="保存当前节点位置(topologyX/topologyY)">
+            <template #icon><SaveOutlined /></template>
+            保存布局
+          </a-button>
+          <a-button @click="refreshData" title="刷新">
+            <template #icon><SvgIcon name="refresh" /></template>
+            刷新
+          </a-button>
+        </a-button-group>
         <a-divider type="vertical" />
         <a-divider type="vertical" />
         <span v-if="mode === 'relation'" class="select-text">双击连线解除关系;双击节点并选中另一节点以建立关系</span>
         <span v-if="mode === 'relation'" class="select-text">双击连线解除关系;双击节点并选中另一节点以建立关系</span>
         <span v-else class="select-text">双击连线解绑或绑定数据布点</span>
         <span v-else class="select-text">双击连线解绑或绑定数据布点</span>
@@ -104,7 +110,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { computed, onMounted, onUnmounted, watch } from 'vue';
+  import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
   import { SvgIcon } from '/@/components/Icon';
   import { SvgIcon } from '/@/components/Icon';
   import { ApiSelect } from '/@/components/Form';
   import { ApiSelect } from '/@/components/Form';
   import { BasicModal, useModal } from '/@/components/Modal';
   import { BasicModal, useModal } from '/@/components/Modal';
@@ -113,7 +119,7 @@
   import { useTopology } from './hooks/useTopology';
   import { useTopology } from './hooks/useTopology';
   import { getWindrectListNoUsed } from './windTopology.api';
   import { getWindrectListNoUsed } from './windTopology.api';
   import { h } from 'vue';
   import { h } from 'vue';
-  import { ReloadOutlined, PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
+  import { ReloadOutlined, PlusOutlined, MinusOutlined, SaveOutlined } from '@ant-design/icons-vue';
 
 
   /** 图例层级项:按 levelTextMap/levelColorMap 顺序生成(矿井进风 → 矿井回风) */
   /** 图例层级项:按 levelTextMap/levelColorMap 顺序生成(矿井进风 → 矿井回风) */
   const levelList = Object.keys(levelTextMap).map((k) => {
   const levelList = Object.keys(levelTextMap).map((k) => {
@@ -146,9 +152,23 @@
     zoomOut,
     zoomOut,
     resetView,
     resetView,
     clearSelection,
     clearSelection,
+    saveTopologyLayout,
     dispose,
     dispose,
   } = useTopology();
   } = useTopology();
 
 
+  /** 保存布局按钮 loading 状态 */
+  const saving = ref(false);
+
+  /** 保存当前拓扑布局(节点位置 → topologyX/topologyY) */
+  async function handleSaveLayout() {
+    saving.value = true;
+    try {
+      await saveTopologyLayout();
+    } finally {
+      saving.value = false;
+    }
+  }
+
   /** 布点模式绑定弹窗(useModal 钩子调用;destroyOnClose 每次打开重建表单刷新下拉数据) */
   /** 布点模式绑定弹窗(useModal 钩子调用;destroyOnClose 每次打开重建表单刷新下拉数据) */
   const [registerBindModal, { openModal, closeModal }] = useModal();
   const [registerBindModal, { openModal, closeModal }] = useModal();
 
 

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

@@ -2,6 +2,7 @@ import { defHttp } from '/@/utils/http/axios';
 import { useMineDepartmentStore } from '/@/store/modules/mine';
 import { useMineDepartmentStore } from '/@/store/modules/mine';
 import type { MineAreaNode, MineAreaRelationNode, TopologyBundle, WindrectNode } from './windTopology.data';
 import type { MineAreaNode, MineAreaRelationNode, TopologyBundle, WindrectNode } from './windTopology.data';
 import { buildTopologyBundle } from './windTopology.data';
 import { buildTopologyBundle } from './windTopology.data';
+import { RequestOptions } from '/#/axios';
 
 
 enum Api {
 enum Api {
   getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
   getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
@@ -10,6 +11,7 @@ enum Api {
   getWindrectList = '/workingface/windrect/getWindrectList',
   getWindrectList = '/workingface/windrect/getWindrectList',
   getWindrectListNoUsed = '/workingface/windrect/getWindrectListNoUsed',
   getWindrectListNoUsed = '/workingface/windrect/getWindrectListNoUsed',
   updateMineArea = '/workingface/mineArea/updateMineArea',
   updateMineArea = '/workingface/mineArea/updateMineArea',
+  updateMineAreaTopology = '/workingface/mineArea/updateMineAreaTopology',
 }
 }
 
 
 /**
 /**
@@ -33,8 +35,7 @@ export const getTopologyData = async (params?: any): Promise<TopologyBundle> =>
       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 relData =
-      relRes.status === 'fulfilled' ? (relRes.value as any) || {} : (console.error('获取巷道关系失败:', relRes.reason), {});
+    const relData = relRes.status === 'fulfilled' ? (relRes.value as any) || {} : (console.error('获取巷道关系失败:', relRes.reason), {});
     const areaList = Array.isArray(relData.mineAreaList) ? (relData.mineAreaList as MineAreaNode[]) : [];
     const areaList = Array.isArray(relData.mineAreaList) ? (relData.mineAreaList as MineAreaNode[]) : [];
     const relationList = Array.isArray(relData.mineAreaRelationList) ? (relData.mineAreaRelationList as MineAreaRelationNode[]) : [];
     const relationList = Array.isArray(relData.mineAreaRelationList) ? (relData.mineAreaRelationList as MineAreaRelationNode[]) : [];
     const windrectList =
     const windrectList =
@@ -49,11 +50,12 @@ export const getTopologyData = async (params?: any): Promise<TopologyBundle> =>
 };
 };
 
 
 /** 编辑测风点位(绑定/解绑数据布点通过字段:id=测风点位主键、windrectId=绑定数据布点 id) */
 /** 编辑测风点位(绑定/解绑数据布点通过字段:id=测风点位主键、windrectId=绑定数据布点 id) */
-export const updateArea = (params?: any) => defHttp.post({ url: Api.updateMineArea, params });
+export const updateArea = (params?: any, opts?: RequestOptions) => defHttp.post({ url: Api.updateMineArea, params }, opts);
+
+export const updateMineAreaTopology = (params?: any) => defHttp.post({ url: Api.updateMineAreaTopology, params });
 
 
 /** 查询未使用数据布点列表(按 deptId 查询,供布点模式绑定弹窗选择) */
 /** 查询未使用数据布点列表(按 deptId 查询,供布点模式绑定弹窗选择) */
-export const getWindrectListNoUsed = (params?: any) =>
-  defHttp.post({ url: Api.getWindrectListNoUsed, params }, { joinParamsToUrl: true });
+export const getWindrectListNoUsed = (params?: any) => defHttp.post({ url: Api.getWindrectListNoUsed, params }, { joinParamsToUrl: true });
 
 
 /** 绑定 MineArea 关联关系(RequestBody:mineCode/parentId/childId;双击节点+单击节点关联时调用) */
 /** 绑定 MineArea 关联关系(RequestBody:mineCode/parentId/childId;双击节点+单击节点关联时调用) */
 export const addMineAreaRelation = (params?: any) => defHttp.post({ url: Api.addMineAreaRelation, params });
 export const addMineAreaRelation = (params?: any) => defHttp.post({ url: Api.addMineAreaRelation, params });

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

@@ -27,20 +27,24 @@ export const JUDGE_WIDTH = 6;
 
 
 /** 测风点位通风类型层级(level 字段语义),用于按层级分列绘制拓扑 */
 /** 测风点位通风类型层级(level 字段语义),用于按层级分列绘制拓扑 */
 export const levelTextMap: Record<number, string> = {
 export const levelTextMap: Record<number, string> = {
+  0: '进风井',
   1: '矿井进风',
   1: '矿井进风',
   2: '采区进风',
   2: '采区进风',
   3: '采区用风',
   3: '采区用风',
   4: '采区回风',
   4: '采区回风',
   5: '矿井回风',
   5: '矿井回风',
+  6: '回风井',
 };
 };
 
 
-/** 测风点位层级配色(5 级互不混淆,避开地面紫 #722ed1),用于按层级区分巷道连线颜色 */
+/** 测风点位层级配色(7 级互不混淆,避开地面紫 #722ed1),用于按层级区分巷道连线颜色 */
 export const levelColorMap: Record<number, string> = {
 export const levelColorMap: Record<number, string> = {
+  0: '#52c41a', // 进风井
   1: '#f5222d', // 矿井进风
   1: '#f5222d', // 矿井进风
   2: '#fa8c16', // 采区进风
   2: '#fa8c16', // 采区进风
   3: '#fadb14', // 采区用风
   3: '#fadb14', // 采区用风
   4: '#13c2c2', // 采区回风
   4: '#13c2c2', // 采区回风
   5: '#eb2f96', // 矿井回风
   5: '#eb2f96', // 矿井回风
+  6: '#1677ff', // 回风井
 };
 };
 
 
 // ==================== 通风示意图布局参数 ====================
 // ==================== 通风示意图布局参数 ====================
@@ -51,22 +55,18 @@ export const LAYOUT = {
   margin: 50,
   margin: 50,
   /** 列间距(x 轴间隔,固定) */
   /** 列间距(x 轴间隔,固定) */
   colGap: 700,
   colGap: 700,
+  /** 保存布局时坐标对齐网格大小(px):保存时 topologyX/topologyY 按此取整,实现对齐效果;调整此值即可配置 */
+  snapGrid: 50,
   /**
   /**
    * 各层级行间距(y 轴间隔,固定,按层级分别配置);
    * 各层级行间距(y 轴间隔,固定,按层级分别配置);
    * 未配置的层级回退 rowGapDefault。
    * 未配置的层级回退 rowGapDefault。
    */
    */
-  rowGap: { 1: 300, 2: 300, 3: 200, 4: 300, 5: 300, ungraded: 200 } as Record<number, number> & { ungraded: number },
+  rowGap: { 0: 300, 1: 300, 2: 300, 3: 200, 4: 300, 5: 300, 6: 300, ungraded: 200 } as Record<number, number> & { ungraded: number },
   /** 未在 rowGap 中配置的层级使用的行距(px) */
   /** 未在 rowGap 中配置的层级使用的行距(px) */
   rowGapDefault: 200,
   rowGapDefault: 200,
 };
 };
 
 
-/** 巷道分支风量标注文本(通风网络图风格:在巷道连线上标注测风点位风量),无风量返回空串 */
-export function formatAirVolumeLabel(airVolume?: number): string {
-  return airVolume === undefined || airVolume === null ? '' : `${airVolume}m³/min`;
-}
-
-/** 画布宽高占位(chart 100% 容器,此处设参考值) */
-export const LAYOUT_WIDTH = 1200;
+/** 画布高度占位(chart 100% 容器,此处设参考值;高度过小时兜底使用) */
 export const LAYOUT_HEIGHT = 700;
 export const LAYOUT_HEIGHT = 700;
 
 
 // ==================== ECharts graph 基础配置 ====================
 // ==================== ECharts graph 基础配置 ====================
@@ -97,7 +97,6 @@ export function createGraphOption(): EChartsOption {
           formatter: (p: any) => p?.data?.midLabel || '',
           formatter: (p: any) => p?.data?.midLabel || '',
           rich: {
           rich: {
             name: { fontSize: 14, color: '#333', fontWeight: 'bold' },
             name: { fontSize: 14, color: '#333', fontWeight: 'bold' },
-            // volume: { fontSize: 12, color: '#666', lineHeight: 18 },
             dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
             dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
             device: { fontSize: 12, color: '#333', lineHeight: 18 },
             device: { fontSize: 12, color: '#333', lineHeight: 18 },
             dotEmpty: { fontSize: 12, color: '#d9d9d9', lineHeight: 18 },
             dotEmpty: { fontSize: 12, color: '#d9d9d9', lineHeight: 18 },
@@ -140,7 +139,6 @@ export interface DetailField {
 export const nodeDetailFields: Record<string, DetailField[]> = {
 export const nodeDetailFields: Record<string, DetailField[]> = {
   地面: [{ label: '节点名称', key: 'name' }],
   地面: [{ label: '节点名称', key: 'name' }],
   测风点位: [
   测风点位: [
-    // { label: '测风点位名称', key: 'name' },
     { label: '层级', key: 'levelText' },
     { label: '层级', key: 'levelText' },
     { label: '需风量(m³/min)', key: 'airVolume' },
     { label: '需风量(m³/min)', key: 'airVolume' },
   ],
   ],
@@ -166,6 +164,9 @@ export interface TopoNodeData {
   airVolume?: number;
   airVolume?: number;
   windrectId?: string;
   windrectId?: string;
   mineCode?: string;
   mineCode?: string;
+  /** 布局保存坐标(用户拖动后保存的 ECharts 画布像素坐标,加载时优先使用) */
+  topologyX?: number | string;
+  topologyY?: number | string;
   /** 疑似隐蔽工作面标记(mineArea.judgeAreaList 字段有内容 → true,对应连线标红加粗) */
   /** 疑似隐蔽工作面标记(mineArea.judgeAreaList 字段有内容 → true,对应连线标红加粗) */
   suspected?: boolean;
   suspected?: boolean;
   [key: string]: any;
   [key: string]: any;
@@ -202,10 +203,13 @@ export interface MineAreaNode {
   id: string;
   id: string;
   mineCode: string;
   mineCode: string;
   name: string;
   name: string;
-  level: number; // 1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风
+  level: number; // 0=进风井 1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风 6=回风井
   airVolume: number;
   airVolume: number;
   regulationId?: string;
   regulationId?: string;
   windrectId?: string;
   windrectId?: string;
+  /** 布局保存坐标(用户拖动后保存的 ECharts 画布像素坐标) */
+  topologyX?: number | string;
+  topologyY?: number | string;
   /** 疑似隐蔽工作面数据(有内容时该点位对应连线标红加粗;兼容历史字段 alarmList) */
   /** 疑似隐蔽工作面数据(有内容时该点位对应连线标红加粗;兼容历史字段 alarmList) */
   judgeAreaList?: any;
   judgeAreaList?: any;
   createTime?: string;
   createTime?: string;
@@ -318,15 +322,22 @@ export function transformToTopologyData(mineAreas: MineAreaNode[], relations: Ar
     links.push({ source: parentId, target: childId, kind: 'roadway', flow, pointId: childId, mainPointId, relationId: rel.id });
     links.push({ source: parentId, target: childId, kind: 'roadway', flow, pointId: childId, mainPointId, relationId: rel.id });
   }
   }
 
 
-  // 地面(总进)→ lv1 巷道点(进风起点根边,无关系 id;主要数据=lv1 子节点)
+  // 动态根边(无关系 id,前端按层级生成):
+  // 进风根边:总进 → 最低有效进风层(优先 lv0 进风井,缺失回退 lv1);
+  // 回风根边:最高有效回风层(优先 lv6 回风井,缺失回退 lv5)→ 总回。
+  // 其余层级间的连接(lv0↔lv1、lv5↔lv6 等)由巷道关系数据驱动。
+  const pointLevels = new Set<number>();
+  for (const a of pointById.values()) pointLevels.add(Number(a.level));
+  const intakeRootLevel = pointLevels.has(0) ? 0 : 1;
+  const returnRootLevel = pointLevels.has(6) ? 6 : 5;
+
   for (const [pid, a] of pointById) {
   for (const [pid, a] of pointById) {
-    if (Number(a.level) === 1) {
+    if (Number(a.level) === intakeRootLevel) {
       links.push({ source: ROOT_IN_ID, target: pid, kind: 'roadway', flow: 'intake', pointId: pid, mainPointId: pid });
       links.push({ source: ROOT_IN_ID, target: pid, kind: 'roadway', flow: 'intake', pointId: pid, mainPointId: pid });
     }
     }
   }
   }
-  // lv5 巷道点 → 地面(总回)(回风终点根边,无关系 id;主要数据=lv5 父节点)
   for (const [pid, a] of pointById) {
   for (const [pid, a] of pointById) {
-    if (Number(a.level) === 5) {
+    if (Number(a.level) === returnRootLevel) {
       links.push({ source: pid, target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: pid, mainPointId: pid });
       links.push({ source: pid, target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: pid, mainPointId: pid });
     }
     }
   }
   }
@@ -347,6 +358,8 @@ export function transformToTopologyData(mineAreas: MineAreaNode[], relations: Ar
       windrectId: a.windrectId,
       windrectId: a.windrectId,
       level: a.level,
       level: a.level,
       levelText: levelTextMap[a.level] || '',
       levelText: levelTextMap[a.level] || '',
+      topologyX: a.topologyX,
+      topologyY: a.topologyY,
       suspected: hasJudgeContent(a),
       suspected: hasJudgeContent(a),
     })),
     })),
   ];
   ];

+ 135 - 129
src/views/dashboard/HiddenSurface/configurable.data.ts

@@ -36,32 +36,32 @@ export const testConfigProvinceMonitor: Config[] = [
             {
             {
               label: '接入矿井总数',
               label: '接入矿井总数',
               value: '${accessMineNum}',
               value: '${accessMineNum}',
-              to: '/basicinfo/mines-info',
+              to: '/basicinfo/mines-info?accessStatus=1',
             },
             },
             {
             {
               label: '测风点在线数',
               label: '测风点在线数',
               value: '${areaOnlineNum}',
               value: '${areaOnlineNum}',
-              to: '/basicinfo/mines-info',
+              to: '/warningAnalysis/wind-point-manage',
             },
             },
             {
             {
               label: '风量异常矿井',
               label: '风量异常矿井',
               value: '${exceptionNum}',
               value: '${exceptionNum}',
-              to: '/warningAnalysis/globalAlarm',
+              to: '/warningAnalysis/global-alarm',
             },
             },
             {
             {
               label: '疑似隐蔽工作面',
               label: '疑似隐蔽工作面',
               value: '${suspectedNum}',
               value: '${suspectedNum}',
-              to: '/warningAnalysis/globalAlarm',
+              to: '/basicinfo/mines-info?judgeResult=1',
             },
             },
             {
             {
               label: '测风点总数',
               label: '测风点总数',
               value: '${areaNum}',
               value: '${areaNum}',
-              to: '/basicinfo/mines-info',
+              to: '/warningAnalysis/wind-point-manage',
             },
             },
             {
             {
               label: '掉线测风点',
               label: '掉线测风点',
               value: '${disconnectNum.length}',
               value: '${disconnectNum.length}',
-              to: '/basicinfo/mines-info',
+              to: '/warningAnalysis/wind-point-manage',
             },
             },
           ],
           ],
         },
         },
@@ -96,10 +96,10 @@ export const testConfigProvinceMonitor: Config[] = [
           type: 'B',
           type: 'B',
           readFrom: 'enforceOffices',
           readFrom: 'enforceOffices',
           columns: [
           columns: [
-            { name: '执法处', prop: 'name' },
+            { name: '执法处', prop: 'name', path: '/basicinfo/mines-info?deptId=${deptId}' },
             { name: '矿井数', prop: 'mineNum' },
             { name: '矿井数', prop: 'mineNum' },
             // { name: '风量异常', prop: 'exceptionNum' },
             // { name: '风量异常', prop: 'exceptionNum' },
-            { name: '疑似隐蔽工作面', prop: 'suspectedNum' },
+            { name: '疑似隐蔽工作面', prop: 'suspectedNum', path: '/basicinfo/mines-info?judgeResult=1&deptId=${deptId}' },
           ],
           ],
         },
         },
       ],
       ],
@@ -107,91 +107,91 @@ export const testConfigProvinceMonitor: Config[] = [
       complex_list: [],
       complex_list: [],
       preset: [],
       preset: [],
     },
     },
-    showStyle: {
-      size: 'width:400px;height:370px;',
-      version: '原版',
-      position: 'top:30px;right:25px;',
-    },
-  },
-  // 3. 风速风量传感器(左下角,饼图统计 getMineData 中风量异常矿井数)
-  {
-    deviceType: 'wind_speed_sensor',
-    moduleName: '风速风量传感器',
-    pageType: 'hidden_surface',
-    moduleData: {
-      header: {
-        show: true,
-        readFrom: '',
-        selector: { show: false, value: '' },
-        slot: { show: false, value: '' },
-      },
-      background: { show: false, type: 'video', link: '' },
-      layout: { direction: 'column', items: [{ name: 'chart', basis: '100%' }] },
-      board: [],
-      chart: [
-        {
-          type: 'pie',
-          readFrom: 'windClosedStats',
-          legend: { show: true },
-          xAxis: [{ show: false }],
-          yAxis: [{ show: false, name: '', position: 'left' }],
-          series: [{ readFrom: '', xprop: 'name', yprop: 'value', label: '' }],
-        },
-      ],
-      gallery: [],
-      gallery_list: [],
-      list: [],
-      complex_list: [],
-      preset: [],
-      table: [],
-    },
     showStyle: {
     showStyle: {
       size: 'width:400px;height:400px;',
       size: 'width:400px;height:400px;',
       version: '原版',
       version: '原版',
       position: 'top:300px;left:25px;',
       position: 'top:300px;left:25px;',
     },
     },
   },
   },
+  // 3. 风速风量传感器(左下角,饼图统计 getMineData 中风量异常矿井数)
+  // {
+  //   deviceType: 'wind_speed_sensor',
+  //   moduleName: '风速风量传感器',
+  //   pageType: 'hidden_surface',
+  //   moduleData: {
+  //     header: {
+  //       show: true,
+  //       readFrom: '',
+  //       selector: { show: false, value: '' },
+  //       slot: { show: false, value: '' },
+  //     },
+  //     background: { show: false, type: 'video', link: '' },
+  //     layout: { direction: 'column', items: [{ name: 'chart', basis: '100%' }] },
+  //     board: [],
+  //     chart: [
+  //       {
+  //         type: 'pie',
+  //         readFrom: 'windClosedStats',
+  //         legend: { show: true },
+  //         xAxis: [{ show: false }],
+  //         yAxis: [{ show: false, name: '', position: 'left' }],
+  //         series: [{ readFrom: '', xprop: 'name', yprop: 'value', label: '' }],
+  //       },
+  //     ],
+  //     gallery: [],
+  //     gallery_list: [],
+  //     list: [],
+  //     complex_list: [],
+  //     preset: [],
+  //     table: [],
+  //   },
+  //   showStyle: {
+  //     size: 'width:400px;height:400px;',
+  //     version: '原版',
+  //     position: 'top:300px;left:25px;',
+  //   },
+  // },
   // 4. 隐蔽工作面(右下角,表格展示)
   // 4. 隐蔽工作面(右下角,表格展示)
-  {
-    deviceType: 'hidden_workface',
-    moduleName: '隐蔽工作面',
-    pageType: 'hidden_surface',
-    moduleData: {
-      header: {
-        show: true,
-        readFrom: '',
-        selector: { show: false, value: '' },
-        slot: { show: false, value: '' },
-      },
-      background: { show: false, type: 'video', link: '' },
-      layout: { direction: 'column', items: [{ name: 'table', basis: '100%', overflow: true }] },
-      board: [],
-      chart: [],
-      gallery: [],
-      gallery_list: [],
-      list: [],
-      complex_list: [],
-      preset: [],
-      table: [
-        {
-          type: 'D',
-          readFrom: 'hiddenWorkfaces',
-          columns: [
-            { name: '矿井名称', prop: 'mineName' },
-            // { name: '父级组织', prop: 'parentArea' },
-            // { name: '判识结果', prop: 'judgeResult' },
-            { name: '风量异常数', prop: 'm3Exception' },
-            // { name: '最近实时上传', prop: 'lastCdssTime' },
-          ],
-        },
-      ],
-    },
-    showStyle: {
-      size: 'width:400px;height:500px;',
-      version: '原版',
-      position: 'top:400px;right:25px;',
-    },
-  },
+  // {
+  //   deviceType: 'hidden_workface',
+  //   moduleName: '隐蔽工作面',
+  //   pageType: 'hidden_surface',
+  //   moduleData: {
+  //     header: {
+  //       show: true,
+  //       readFrom: '',
+  //       selector: { show: false, value: '' },
+  //       slot: { show: false, value: '' },
+  //     },
+  //     background: { show: false, type: 'video', link: '' },
+  //     layout: { direction: 'column', items: [{ name: 'table', basis: '100%', overflow: true }] },
+  //     board: [],
+  //     chart: [],
+  //     gallery: [],
+  //     gallery_list: [],
+  //     list: [],
+  //     complex_list: [],
+  //     preset: [],
+  //     table: [
+  //       {
+  //         type: 'D',
+  //         readFrom: 'hiddenWorkfaces',
+  //         columns: [
+  //           { name: '矿井名称', prop: 'mineName' },
+  //           // { name: '父级组织', prop: 'parentArea' },
+  //           // { name: '判识结果', prop: 'judgeResult' },
+  //           { name: '风量异常数', prop: 'm3Exception' },
+  //           // { name: '最近实时上传', prop: 'lastCdssTime' },
+  //         ],
+  //       },
+  //     ],
+  //   },
+  //   showStyle: {
+  //     size: 'width:400px;height:500px;',
+  //     version: '原版',
+  //     position: 'top:400px;right:25px;',
+  //   },
+  // },
 ];
 ];
 
 
 /**
 /**
@@ -230,26 +230,32 @@ export const testConfigVentilationMonitor: Config[] = [
             {
             {
               label: '测风点总数',
               label: '测风点总数',
               value: '${areaNum}',
               value: '${areaNum}',
+              to: '/warningAnalysis/wind-point-manage',
             },
             },
             {
             {
               label: '在线测风点',
               label: '在线测风点',
               value: '${areaOnlineNum}',
               value: '${areaOnlineNum}',
+              to: '/warningAnalysis/wind-point-manage',
             },
             },
             {
             {
               label: '风量异常',
               label: '风量异常',
               value: '${exceptionNum}',
               value: '${exceptionNum}',
+              to: '/warningAnalysis/global-alarm',
             },
             },
             {
             {
               label: '疑似隐蔽工作面',
               label: '疑似隐蔽工作面',
               value: '${suspectedNum}',
               value: '${suspectedNum}',
+              to: '/basicinfo/mines-info?judgeResult=1',
             },
             },
             {
             {
               label: '风速超限',
               label: '风速超限',
               value: '${vaOverLimitNum}',
               value: '${vaOverLimitNum}',
+              to: '/warningAnalysis/global-alarm',
             },
             },
             {
             {
               label: '掉线测风点',
               label: '掉线测风点',
               value: '${disconnectNum.length}',
               value: '${disconnectNum.length}',
+              to: '/warningAnalysis/wind-point-manage',
             },
             },
           ],
           ],
         },
         },
@@ -293,49 +299,8 @@ export const testConfigVentilationMonitor: Config[] = [
             { label: '测风地点数', prop: 'mineAreaCount', unit: '个' },
             { label: '测风地点数', prop: 'mineAreaCount', unit: '个' },
             { label: '测风装置数', prop: 'windrectCount', unit: '个' },
             { label: '测风装置数', prop: 'windrectCount', unit: '个' },
           ],
           ],
-          // suggestions: ['请现场核查疑似隐蔽巷道的通风设施状态', '缺失测风点应尽快补装并接入系统'],
-          suggestions: ['暂无建议'],
-        },
-      ],
-    },
-    showStyle: {
-      size: 'width:400px;height:480px;',
-      version: '原版',
-      position: 'top:30px;right:25px;',
-    },
-  },
-  // 3. 风速风量传感器(左下角,表格展示测风点实时数据)
-  {
-    deviceType: 'wind_speed_sensor',
-    moduleName: '风速风量传感器',
-    pageType: 'hidden_surface',
-    moduleData: {
-      header: {
-        show: true,
-        readFrom: '',
-        selector: { show: false, value: '' },
-        slot: { show: false, value: '' },
-      },
-      background: { show: false, type: 'video', link: '' },
-      layout: { direction: 'column', items: [{ name: 'table', basis: '100%', overflow: true }] },
-      board: [],
-      chart: [],
-      gallery: [],
-      gallery_list: [],
-      list: [],
-      complex_list: [],
-      preset: [],
-      table: [
-        {
-          type: 'D',
-          readFrom: 'pointRecords',
-          columns: [
-            { name: '矿井名称', prop: 'mineName' },
-            { name: '测风地点', prop: 'mineAreaName' },
-            { name: '风速(m/s)', prop: 'va' },
-            { name: '风量(m³/min)', prop: 'm3' },
-            // { name: '状态', prop: 'status' },
-          ],
+          suggestions: ['请现场核查疑似隐蔽巷道的通风设施状态', '缺失测风点应尽快补装并接入系统'],
+          // suggestions: ['暂无建议'],
         },
         },
       ],
       ],
     },
     },
@@ -345,4 +310,45 @@ export const testConfigVentilationMonitor: Config[] = [
       position: 'top:350px;left:25px;',
       position: 'top:350px;left:25px;',
     },
     },
   },
   },
+  // 3. 风速风量传感器(左下角,表格展示测风点实时数据)
+  // {
+  //   deviceType: 'wind_speed_sensor',
+  //   moduleName: '风速风量传感器',
+  //   pageType: 'hidden_surface',
+  //   moduleData: {
+  //     header: {
+  //       show: true,
+  //       readFrom: '',
+  //       selector: { show: false, value: '' },
+  //       slot: { show: false, value: '' },
+  //     },
+  //     background: { show: false, type: 'video', link: '' },
+  //     layout: { direction: 'column', items: [{ name: 'table', basis: '100%', overflow: true }] },
+  //     board: [],
+  //     chart: [],
+  //     gallery: [],
+  //     gallery_list: [],
+  //     list: [],
+  //     complex_list: [],
+  //     preset: [],
+  //     table: [
+  //       {
+  //         type: 'D',
+  //         readFrom: 'pointRecords',
+  //         columns: [
+  //           { name: '矿井名称', prop: 'mineName' },
+  //           { name: '测风地点', prop: 'mineAreaName' },
+  //           { name: '风速(m/s)', prop: 'va' },
+  //           { name: '风量(m³/min)', prop: 'm3' },
+  //           // { name: '状态', prop: 'status' },
+  //         ],
+  //       },
+  //     ],
+  //   },
+  //   showStyle: {
+  //     size: 'width:400px;height:500px;',
+  //     version: '原版',
+  //     position: 'top:350px;left:25px;',
+  //   },
+  // },
 ];
 ];

+ 1 - 1
src/views/dashboard/HiddenSurface/index.vue

@@ -266,7 +266,7 @@
     width: 100%;
     width: 100%;
     height: 100%;
     height: 100%;
     color: @white;
     color: @white;
-    background-image: linear-gradient(90deg, @map-bg 0%, @map-bg 20%, transparent 30%, transparent 70%, @map-bg 80%, @map-bg 100%);
+    background-image: linear-gradient(90deg, @map-bg 0%, @map-bg 20%, transparent 30%, transparent 100%);
     background-repeat: no-repeat;
     background-repeat: no-repeat;
     background-size: 100% 100%;
     background-size: 100% 100%;
     z-index: @layout-basic-z-index;
     z-index: @layout-basic-z-index;

+ 1 - 1
src/views/dashboard/SealedGoaf/index.vue

@@ -348,7 +348,7 @@
     width: 100%;
     width: 100%;
     height: 100%;
     height: 100%;
     color: @white;
     color: @white;
-    background-image: linear-gradient(90deg, @map-bg 0%, @map-bg 20%, transparent 30%, transparent 70%, @map-bg 80%, @map-bg 100%);
+    background-image: linear-gradient(90deg, @map-bg 0%, @map-bg 20%, transparent 30%, transparent 100%);
     background-repeat: no-repeat;
     background-repeat: no-repeat;
     background-size: 100% 100%;
     background-size: 100% 100%;
     z-index: @layout-basic-z-index;
     z-index: @layout-basic-z-index;

+ 2 - 0
src/views/dashboard/basicInfo/minesInfo/index.vue

@@ -47,12 +47,14 @@
           accessStatus: route.query.accessStatus,
           accessStatus: route.query.accessStatus,
           status: route.query.status,
           status: route.query.status,
           gjMineStatus: route.query.gjMineStatus,
           gjMineStatus: route.query.gjMineStatus,
+          judgeResult: route.query.judgeResult,
         },
         },
         labelWidth: 120,
         labelWidth: 120,
         schemas: searchFormSchema, // 绑定动态搜索表单
         schemas: searchFormSchema, // 绑定动态搜索表单
         showAdvancedButton: false,
         showAdvancedButton: false,
         schemaGroupNames: ['常规查询'],
         schemaGroupNames: ['常规查询'],
       },
       },
+      actionColumn: { ifShow: false },
       showIndexColumn: false,
       showIndexColumn: false,
       scroll: { x: 'max-content' },
       scroll: { x: 'max-content' },
     },
     },

+ 3 - 3
src/views/monitor/sensorMonitor/index.vue

@@ -2,7 +2,7 @@
 <template>
 <template>
   <div class="sensor-monitor">
   <div class="sensor-monitor">
     <!-- 顶部统计卡片(getCurrentStatistics) -->
     <!-- 顶部统计卡片(getCurrentStatistics) -->
-    <div class="board-info">
+    <!-- <div class="board-info">
       <MiniBoard
       <MiniBoard
         v-for="(item, index) in boardDataRef"
         v-for="(item, index) in boardDataRef"
         :key="index"
         :key="index"
@@ -12,7 +12,7 @@
         layout="label-top"
         layout="label-top"
         class="board-item"
         class="board-item"
       />
       />
-    </div>
+    </div> -->
 
 
     <!-- Tab 切换 -->
     <!-- Tab 切换 -->
     <Tabs v-model:activeKey="activeTab" type="line" class="common-page-tabs">
     <Tabs v-model:activeKey="activeTab" type="line" class="common-page-tabs">
@@ -56,7 +56,7 @@
   import { ref } from 'vue';
   import { ref } from 'vue';
   import { BasicTable } from '/@/components/Table';
   import { BasicTable } from '/@/components/Table';
   import { Tabs, TabPane } from 'ant-design-vue';
   import { Tabs, TabPane } from 'ant-design-vue';
-  import MiniBoard from '/@/components/Configurable/detail/MiniBoard.vue';
+  // import MiniBoard from '/@/components/Configurable/detail/MiniBoard.vue';
   import { SvgIcon } from '/@/components/Icon';
   import { SvgIcon } from '/@/components/Icon';
   import { useListPage } from '/@/hooks/system/useListPage';
   import { useListPage } from '/@/hooks/system/useListPage';
   import { advancedRoutePush } from '/@/utils';
   import { advancedRoutePush } from '/@/utils';

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

@@ -14,8 +14,8 @@ export const boardData = [
 
 
 /** 数据状态映射(WindrectData.status:1正常、0已删除) */
 /** 数据状态映射(WindrectData.status:1正常、0已删除) */
 export const statusMap: Record<string, string> = {
 export const statusMap: Record<string, string> = {
-  '1': '正常',
-  '0': '已删除',
+  '1': '在线',
+  '0': '离线',
 };
 };
 
 
 /** 风速风量实时监测表格列(WindrectData) */
 /** 风速风量实时监测表格列(WindrectData) */

+ 20 - 6
src/views/monitor/surfaceMonitor/index.vue

@@ -11,6 +11,7 @@
         :value="item.value"
         :value="item.value"
         layout="label-top"
         layout="label-top"
         class="board-item"
         class="board-item"
+        @click.capture="boardClickHandler(item.model)"
       />
       />
     </div>
     </div>
 
 
@@ -62,10 +63,10 @@
     try {
     try {
       const result = await getCurrentStatistics({ deptId: params?.deptId });
       const result = await getCurrentStatistics({ deptId: params?.deptId });
       boardDataRef.value[0].value = result.accessMineNum ?? '-';
       boardDataRef.value[0].value = result.accessMineNum ?? '-';
-      const onlineRate = result.areaNum ? Math.round((result.areaOnlineNum / result.areaNum) * 100) : '-';
-      boardDataRef.value[1].value = onlineRate === '-' ? '-' : `${onlineRate}%`;
-      boardDataRef.value[2].value = result.exceptionNum ?? '-';
-      boardDataRef.value[3].value = result.suspectedNum ?? '-';
+      // const onlineRate = result.areaNum ? Math.round((result.areaOnlineNum / result.areaNum) * 100) : '-';
+      // boardDataRef.value[1].value = onlineRate === '-' ? '-' : `${onlineRate}%`;
+      // boardDataRef.value[2].value = result.exceptionNum ?? '-';
+      boardDataRef.value[1].value = result.suspectedNum ?? '-';
     } catch {
     } catch {
       // 使用默认值
       // 使用默认值
     }
     }
@@ -115,7 +116,7 @@
       params: {},
       params: {},
     },
     },
   });
   });
-  const [registerRealtimeTable] = ctxRealtime;
+  const [registerRealtimeTable, { getForm }] = ctxRealtime;
 
 
   // ====== 历史数据表格(暂无接口,空态占位)======
   // ====== 历史数据表格(暂无接口,空态占位)======
   const { tableContext, onExportXls } = useListPage({
   const { tableContext, onExportXls } = useListPage({
@@ -158,7 +159,20 @@
       params: {},
       params: {},
     },
     },
   });
   });
-  const [registerHistoryTable] = tableContext;
+  const [registerHistoryTable, { getForm: getHistoryForm }] = tableContext;
+
+  async function boardClickHandler(model) {
+    const inst1 = getForm();
+    const inst2 = getHistoryForm();
+    if (inst1) {
+      await getForm()?.setFieldsValue(model);
+      getForm().submit();
+    }
+    if (inst2) {
+      await getHistoryForm()?.setFieldsValue(model);
+      getHistoryForm().submit();
+    }
+  }
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>

+ 14 - 4
src/views/monitor/surfaceMonitor/surfaceMonitor.data.ts

@@ -5,10 +5,10 @@ import dayjs from 'dayjs';
 
 
 /** 顶部统计卡片(暂为占位,接口就绪后接入) */
 /** 顶部统计卡片(暂为占位,接口就绪后接入) */
 export const boardData = [
 export const boardData = [
-  { label: '接入矿井总数', value: '-' },
-  { label: '在线率', value: '-' },
-  { label: '风量异常矿井', value: '-' },
-  { label: '疑似隐蔽工作面', value: '-' },
+  { label: '接入矿井总数', value: '-', model: { accessStatus: 1 } },
+  // { label: '在线率', value: '-' },
+  // { label: '风量异常矿井', value: '-' },
+  { label: '疑似隐蔽工作面', value: '-', model: { judgeResult: 1 } },
 ];
 ];
 
 
 /** 接入状态映射(Mine.accessStatus:1已接入、0未接入) */
 /** 接入状态映射(Mine.accessStatus:1已接入、0未接入) */
@@ -155,6 +155,16 @@ export const schemas: FormSchema[] = [
       ],
       ],
     },
     },
   },
   },
+  {
+    field: 'mkMineStatus',
+    label: '煤矿状态',
+    component: 'JDictSelectTag',
+    componentProps: {
+      dictCode: 'mineProStatus',
+      placeholder: '请选择生产状态',
+    },
+    colProps: { span: 6 },
+  },
   {
   {
     label: '判识结果',
     label: '判识结果',
     field: 'judgeResult',
     field: 'judgeResult',

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

@@ -224,6 +224,7 @@ export function useMap2D(): UseMap2DReturn {
 
 
     if (showInfo) {
     if (showInfo) {
       const alarmStatusDiv = document.createElement('div');
       const alarmStatusDiv = document.createElement('div');
+      alarmStatusDiv.className = 'wf-marker-text';
       alarmStatusDiv.style.cssText = `
       alarmStatusDiv.style.cssText = `
         font-size: 14px;
         font-size: 14px;
         white-space: nowrap;
         white-space: nowrap;
@@ -263,6 +264,7 @@ export function useMap2D(): UseMap2DReturn {
 
 
     if (showInfo) {
     if (showInfo) {
       const devicePosDiv = document.createElement('div');
       const devicePosDiv = document.createElement('div');
+      devicePosDiv.className = 'wf-marker-text';
       devicePosDiv.style.cssText = `
       devicePosDiv.style.cssText = `
         position: absolute;
         position: absolute;
         font-size: 14px;
         font-size: 14px;

+ 74 - 74
src/views/system/cadFile/components/GoafPopup.vue

@@ -22,85 +22,85 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { computed } from 'vue';
+  import { computed } from 'vue';
 
 
-interface Props {
-  data: Record<string, any>;
-}
+  interface Props {
+    data: Record<string, any>;
+  }
 
 
-const props = defineProps<Props>();
+  const props = defineProps<Props>();
 
 
-/** 预警类型中文映射(AlarmController alarmType 枚举),未知类型回退显示原始值 */
-const alarmTypeMap: Record<string, string> = {
-  vaOverLimit: '风速超限',
-  m3Lack: '风量不足',
-  disconnect: '断线',
-  vaChange: '风速波动',
-};
+  /** 预警类型中文映射(AlarmController alarmType 枚举),未知类型回退显示原始值 */
+  const alarmTypeMap: Record<string, string> = {
+    vaOverLimit: '风速超限',
+    m3Lack: '风量不足',
+    disconnect: '断线',
+    vaChange: '风速波动',
+  };
 
 
-const alarmList = computed(() => (Array.isArray(props.data.alarmList) ? props.data.alarmList : []));
+  const alarmList = computed(() => (Array.isArray(props.data.alarmList) ? props.data.alarmList : []));
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.goaf-popup-container {
-  font-family: sans-serif;
-  min-width: 380px;
-  max-height: 600px;
-  border-radius: 8px;
-  padding: 10px 0;
-}
-
-.popup-title {
-  font-size: 16px;
-  font-weight: bold;
-  color: #000;
-  margin-bottom: 10px;
-  text-align: center;
-}
-
-.alarm-table {
-  width: 100%;
-  border-radius: 4px;
-  overflow: hidden;
-  background: #dceefb;
-}
-
-.alarm-table-row {
-  display: flex;
-  align-items: center;
-  padding: 8px 10px;
-  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
-}
-
-.alarm-table-row:last-child {
-  border-bottom: none;
-}
-
-.alarm-table-head {
-  font-weight: bold;
-  color: #000;
-  background: rgba(0, 0, 0, 0.04);
-}
-
-.col-type {
-  flex: 1.4;
-  color: #000;
-  font-size: 14px;
-}
-
-.col-val {
-  flex: 1;
-  color: #2b6ff0;
-  font-size: 14px;
-  text-align: center;
-}
-
-.no-alarm {
-  color: #999;
-  font-size: 14px;
-  text-align: center;
-  padding: 20px 0;
-  background: #dceefb;
-  border-radius: 4px;
-}
+  .goaf-popup-container {
+    font-family: sans-serif;
+    min-width: 380px;
+    max-height: 600px;
+    border-radius: 8px;
+    padding: 10px 0;
+  }
+
+  .popup-title {
+    font-size: 16px;
+    font-weight: bold;
+    color: #eee;
+    margin-bottom: 10px;
+    text-align: center;
+  }
+
+  .alarm-table {
+    width: 100%;
+    border-radius: 4px;
+    overflow: hidden;
+    background: #dceefb;
+  }
+
+  .alarm-table-row {
+    display: flex;
+    align-items: center;
+    padding: 8px 10px;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+  }
+
+  .alarm-table-row:last-child {
+    border-bottom: none;
+  }
+
+  .alarm-table-head {
+    font-weight: bold;
+    color: #eee;
+    background: rgba(0, 0, 0, 0.04);
+  }
+
+  .col-type {
+    flex: 1.4;
+    color: #eee;
+    font-size: 14px;
+  }
+
+  .col-val {
+    flex: 1;
+    color: #2b6ff0;
+    font-size: 14px;
+    text-align: center;
+  }
+
+  .no-alarm {
+    color: #999;
+    font-size: 14px;
+    text-align: center;
+    padding: 20px 0;
+    background: #dceefb;
+    border-radius: 4px;
+  }
 </style>
 </style>

+ 0 - 1
src/views/system/cadFile/components/MapView.vue

@@ -6,7 +6,6 @@
       <a-button :loading="loading" @click="drawerVisible = true" class="">测风点位列表</a-button>
       <a-button :loading="loading" @click="drawerVisible = true" class="">测风点位列表</a-button>
       <a-button @click="emit('close')" class="ml-5px">返回列表</a-button>
       <a-button @click="emit('close')" class="ml-5px">返回列表</a-button>
     </div>
     </div>
-
     <SideDrawer v-model:open="drawerVisible" :mineCode="mineCode" :getContainer="() => map3dContainer" />
     <SideDrawer v-model:open="drawerVisible" :mineCode="mineCode" :getContainer="() => map3dContainer" />
   </div>
   </div>
 </template>
 </template>

+ 11 - 0
src/views/system/cadFile/marker.less

@@ -11,6 +11,7 @@
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
   cursor: pointer;
   cursor: pointer;
+  text-shadow: 0 0 2px @white;
 
 
   /* 传感器标点:绿色实心圆 + 白色描边(z-index 高于波纹层,保证描边清晰) */
   /* 传感器标点:绿色实心圆 + 白色描边(z-index 高于波纹层,保证描边清晰) */
   .wf-marker-dot {
   .wf-marker-dot {
@@ -79,3 +80,13 @@
     opacity: 0;
     opacity: 0;
   }
   }
 }
 }
+
+/* 标记文本白色描边:在 CAD 底图上突出点位位置(text-shadow 为继承属性,作用于父 div 覆盖子 span) */
+.wf-marker-text {
+  text-shadow:
+    -2px -2px 0 #ffffff,
+    2px -2px 0 #ffffff,
+    -2px 2px 0 #ffffff,
+    2px 2px 0 #ffffff,
+    0 0 4px #ffffff;
+}

+ 76 - 16
tests/useTopologyLayout.spec.ts

@@ -1,7 +1,6 @@
 import {
 import {
   buildRelationArray,
   buildRelationArray,
   transformToTopologyData,
   transformToTopologyData,
-  formatAirVolumeLabel,
   LAYOUT,
   LAYOUT,
   ROOT_IN_ID,
   ROOT_IN_ID,
   ROOT_OUT_ID,
   ROOT_OUT_ID,
@@ -101,14 +100,34 @@ describe('transformToTopologyData 巷道线模型', () => {
     expect(data.nodes.find((n) => n.id === 'point:c1')?.suspected).toBe(false);
     expect(data.nodes.find((n) => n.id === 'point:c1')?.suspected).toBe(false);
     expect(data.nodes.find((n) => n.id === 'point:d1')?.suspected).toBe(true);
     expect(data.nodes.find((n) => n.id === 'point:d1')?.suspected).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('');
+  test('topologyX/topologyY 透传到节点(数字与字符串输入)', () => {
+    const areas = [
+      area('a1', 1, 100, { topologyX: 123.5, topologyY: 456 }),
+      area('b1', 2, 90, { topologyX: '200', topologyY: '300' }),
+    ];
+    const data = transformToTopologyData(areas, []);
+    expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyX).toBe(123.5);
+    expect(data.nodes.find((n) => n.id === 'point:a1')?.topologyY).toBe(456);
+    expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyX).toBe('200');
+    expect(data.nodes.find((n) => n.id === 'point:b1')?.topologyY).toBe('300');
+    // 地面节点无该字段
+    expect(data.nodes.find((n) => n.id === ROOT_IN_ID)?.topologyX).toBeUndefined();
+  });
+
+  test('动态根边:有 lv0/lv6 时 总进→lv0、lv6→总回;缺失时回退 lv1/lv5', () => {
+    // 场景一:存在 lv0(进风井) 与 lv6(回风井) → 根边连 lv0/lv6,不再连 lv1/lv5
+    const areas1 = [area('z0', 0), area('a1', 1), area('e1', 5), area('z6', 6)];
+    const data1 = transformToTopologyData(areas1, []);
+    expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:z0')).toBe(true);
+    expect(data1.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(false);
+    expect(data1.links.some((x) => x.source === 'point:z6' && x.target === ROOT_OUT_ID)).toBe(true);
+    expect(data1.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(false);
+    // 场景二:无 lv0/lv6 → 回退 总进→lv1、lv5→总回
+    const areas2 = [area('a1', 1), area('e1', 5)];
+    const data2 = transformToTopologyData(areas2, []);
+    expect(data2.links.some((x) => x.source === ROOT_IN_ID && x.target === 'point:a1')).toBe(true);
+    expect(data2.links.some((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID)).toBe(true);
   });
   });
 });
 });
 
 
@@ -116,20 +135,32 @@ describe('computeLayout 列式布局(固定间距)', () => {
   const centerY = 350; // 700 / 2
   const centerY = 350; // 700 / 2
   const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
   const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
 
 
-  test('列序:总进 → lv1..lv5 → 总回,x 依次递增固定 colGap;单节点列垂直居中', () => {
+  test('列序:总进 → lv0..lv6 → 总回,x 依次递增固定 colGap;单节点列垂直居中', () => {
     const layout = computeLayout(laneTopo(), 1200, 700);
     const layout = computeLayout(laneTopo(), 1200, 700);
     const pos = layout.positions;
     const pos = layout.positions;
     expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
     expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
-    expect(pos['point:a1'].x).toBe(laneX(1));
-    expect(pos['point:b1'].x).toBe(laneX(2));
-    expect(pos['point:c1'].x).toBe(laneX(3));
-    expect(pos['point:d1'].x).toBe(laneX(4));
-    expect(pos['point:e1'].x).toBe(laneX(5));
-    expect(pos[ROOT_OUT_ID].x).toBe(laneX(7)); // 未分级列之后为总回
+    // 列序:总进(0) → lv0(1) → lv1(2) → lv2(3) → lv3(4) → lv4(5) → lv5(6) → lv6(7) → 未分级(8) → 总回(9)
+    expect(pos['point:a1'].x).toBe(laneX(2));
+    expect(pos['point:b1'].x).toBe(laneX(3));
+    expect(pos['point:c1'].x).toBe(laneX(4));
+    expect(pos['point:d1'].x).toBe(laneX(5));
+    expect(pos['point:e1'].x).toBe(laneX(6));
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回
     expect(pos[ROOT_IN_ID].y).toBe(centerY);
     expect(pos[ROOT_IN_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
   });
   });
 
 
+  test('新增层级 lv0(进风井)/lv6(回风井) 列序:位于 lv1 之前、lv5 之后', () => {
+    const areas = [area('a1', 1), area('z0', 0), area('e1', 5), area('z6', 6)];
+    const data = transformToTopologyData(areas, []);
+    const pos = computeLayout(data, 1200, 700).positions;
+    expect(pos['point:z0'].x).toBe(laneX(1)); // lv0 在 lv1 前
+    expect(pos['point:a1'].x).toBe(laneX(2));
+    expect(pos['point:e1'].x).toBe(laneX(6));
+    expect(pos['point:z6'].x).toBe(laneX(7)); // lv6 在 lv5 后
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(9));
+  });
+
   test('同层级同列,行距 = 该层级 rowGap(固定),围绕中心对称', () => {
   test('同层级同列,行距 = 该层级 rowGap(固定),围绕中心对称', () => {
     const data = laneTopo();
     const data = laneTopo();
     data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
     data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
@@ -145,8 +176,9 @@ describe('computeLayout 列式布局(固定间距)', () => {
     try {
     try {
       LAYOUT.colGap = 250;
       LAYOUT.colGap = 250;
       const pos = computeLayout(laneTopo(), 1200, 700).positions;
       const pos = computeLayout(laneTopo(), 1200, 700).positions;
-      expect(pos['point:a1'].x - pos[ROOT_IN_ID].x).toBe(250);
+      // 相邻层级列间距 = colGap(a1 为 lv1,与总进之间隔 lv0 空列)
       expect(pos['point:b1'].x - pos['point:a1'].x).toBe(250);
       expect(pos['point:b1'].x - pos['point:a1'].x).toBe(250);
+      expect(pos['point:c1'].x - pos['point:b1'].x).toBe(250);
     } finally {
     } finally {
       LAYOUT.colGap = orig;
       LAYOUT.colGap = orig;
     }
     }
@@ -265,4 +297,32 @@ describe('computeLayout 列式布局(固定间距)', () => {
     expect(layout.positions[ROOT_IN_ID].y).toBe(centerY);
     expect(layout.positions[ROOT_IN_ID].y).toBe(centerY);
     expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY);
     expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY);
   });
   });
+
+  test('布局保存坐标覆盖:有效坐标直接使用;null/空串/0/NaN 等无效坐标回退列布局(不挤到原点)', () => {
+    const areas = [
+      area('a1', 1, 100, { topologyX: 888, topologyY: 666 }),
+      area('b1', 2, 90, { topologyX: '999.5', topologyY: '333' }), // 字符串数字同样生效
+      area('c1', 3, 80, { topologyX: 'abc', topologyY: NaN }), // 非数字 → 回退
+      area('d1', 4, 70), // 无坐标 → 回退
+      area('e1', 5, 60, { topologyX: 0, topologyY: null }), // 后端默认 0/null → 回退
+      area('f1', 0, 50, { topologyX: '', topologyY: '0' }), // 空串/'0' → 回退(lv0 进风井列)
+    ];
+    const data = transformToTopologyData(areas, []);
+    const pos = computeLayout(data, 1200, 700).positions;
+    // 有效保存坐标直接使用
+    expect(pos['point:a1']).toEqual({ x: 888, y: 666 });
+    expect(pos['point:b1']).toEqual({ x: 999.5, y: 333 });
+    // c1 非法坐标回退列布局(lv3 列,垂直居中),而非覆盖到 (0,0)
+    expect(pos['point:c1'].x).toBe(laneX(4)); // 总进(0) lv0(1) lv1(2) lv2(3) lv3(4)
+    expect(pos['point:c1'].y).toBe(centerY);
+    // 无坐标节点正常列布局
+    expect(pos['point:d1'].x).toBe(laneX(5)); // lv4
+    // 后端默认 0/null/''/0 字符串:回退列布局,绝不挤到原点
+    expect(pos['point:e1'].x).toBe(laneX(6)); // lv5
+    expect(pos['point:e1'].y).toBe(centerY);
+    expect(pos['point:f1'].x).toBe(laneX(1)); // lv0 进风井列
+    expect(pos['point:f1'].y).toBe(centerY);
+    expect(pos['point:e1'].x).not.toBe(0);
+    expect(pos['point:f1'].y).not.toBe(0);
+  });
 });
 });

+ 32 - 9
tests/useTopologyOrdering.spec.ts

@@ -39,7 +39,7 @@ function pairEdgesOf(data: TopologyData): Map<string, Array<[string, string]>> {
   for (const n of data.nodes) {
   for (const n of data.nodes) {
     if (n.category !== 1) continue;
     if (n.category !== 1) continue;
     const lv = Number(n.level);
     const lv = Number(n.level);
-    if (Number.isFinite(lv) && lv >= 1 && lv <= 5) levelById.set(n.id, lv);
+    if (Number.isFinite(lv) && lv >= 0 && lv <= 6) levelById.set(n.id, lv);
   }
   }
   const map = new Map<string, Array<[string, string]>>();
   const map = new Map<string, Array<[string, string]>>();
   for (const l of data.links) {
   for (const l of data.links) {
@@ -114,7 +114,8 @@ describe('orderNodesByLevel median 扫描法', () => {
   });
   });
 
 
   test('未分级列保持输入序,不参与扫描', () => {
   test('未分级列保持输入序,不参与扫描', () => {
-    const areas = [area('g1', 0), area('g2', 0), area('g3', 0)];
+    // level 0/6 已是有效层级(进风井/回风井),未分级用越界值 7 表示
+    const areas = [area('g1', 7), area('g2', 7), area('g3', 7)];
     const data = transformToTopologyData(areas, []);
     const data = transformToTopologyData(areas, []);
     const order = orderNodesByLevel(data);
     const order = orderNodesByLevel(data);
     expect(order.get('ungraded')).toEqual(['point:g1', 'point:g2', 'point:g3']);
     expect(order.get('ungraded')).toEqual(['point:g1', 'point:g2', 'point:g3']);
@@ -138,7 +139,7 @@ describe('orderNodesByLevel median 扫描法', () => {
       links: [],
       links: [],
     };
     };
     const order = orderNodesByLevel(data);
     const order = orderNodesByLevel(data);
-    for (const key of ['1', '2', '3', '4', '5', 'ungraded']) {
+    for (const key of ['0', '1', '2', '3', '4', '5', '6', 'ungraded']) {
       expect(order.get(key)).toEqual([]);
       expect(order.get(key)).toEqual([]);
     }
     }
   });
   });
@@ -158,6 +159,28 @@ describe('orderNodesByLevel median 扫描法', () => {
     expect(order.get('1')).toEqual(['point:a1', 'point:a2']);
     expect(order.get('1')).toEqual(['point:a1', 'point:a2']);
     expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
     expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
   });
   });
+
+  test('新增层级 lv0(进风井)/lv6(回风井) 参与排序:相邻层对 0-1、5-6 交叉消除', () => {
+    // lv0=[a0,a1]、lv1=[b1,b2]:边 a0→b2、a1→b1 → 初始 1 交叉
+    // lv5=[e1,e2]、lv6=[f1,f2]:边 e1→f2、e2→f1 → 初始 1 交叉
+    const areas = [area('a0', 0), area('a1', 0), area('b1', 1), area('b2', 1), area('e1', 5), area('e2', 5), area('f1', 6), area('f2', 6)];
+    const relations = buildRelationArray(
+      areas,
+      [
+        rel('r1', 'a0', 'b2'),
+        rel('r2', 'a1', 'b1'),
+        rel('r3', 'e1', 'f2'),
+        rel('r4', 'e2', 'f1'),
+      ],
+    );
+    const data = transformToTopologyData(areas, relations);
+    const order = orderNodesByLevel(data);
+    expect(order.get('0')).toHaveLength(2);
+    expect(order.get('1')).toHaveLength(2);
+    expect(order.get('5')).toHaveLength(2);
+    expect(order.get('6')).toHaveLength(2);
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
 });
 });
 
 
 describe('computeLayout 与排序集成', () => {
 describe('computeLayout 与排序集成', () => {
@@ -180,8 +203,8 @@ describe('computeLayout 与排序集成', () => {
     // x 列序不变:lv1 同列、lv2 同列
     // x 列序不变:lv1 同列、lv2 同列
     expect(pos['point:a1'].x).toBe(pos['point:a2'].x);
     expect(pos['point:a1'].x).toBe(pos['point:a2'].x);
     expect(pos['point:b1'].x).toBe(pos['point:b2'].x);
     expect(pos['point:b1'].x).toBe(pos['point:b2'].x);
-    expect(pos['point:a1'].x).toBe(laneX(1));
-    expect(pos['point:b1'].x).toBe(laneX(2));
+    expect(pos['point:a1'].x).toBe(laneX(2)); // 总进(0) lv0(1) lv1(2)
+    expect(pos['point:b1'].x).toBe(laneX(3));
     // 排序生效:lv2 重排为 [b2, b1](b2 在 b1 上方),消除交叉
     // 排序生效:lv2 重排为 [b2, b1](b2 在 b1 上方),消除交叉
     expect(pos['point:b2'].y).toBeLessThan(pos['point:b1'].y);
     expect(pos['point:b2'].y).toBeLessThan(pos['point:b1'].y);
     // 每列仍围绕中心对称
     // 每列仍围绕中心对称
@@ -217,9 +240,9 @@ describe('computeLayout 与排序集成', () => {
     const data = transformToTopologyData(areas, relations);
     const data = transformToTopologyData(areas, relations);
     const pos = computeLayout(data, 1200, 700).positions;
     const pos = computeLayout(data, 1200, 700).positions;
     expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
     expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
-    expect(pos['point:a1'].x).toBe(laneX(1));
-    expect(pos['point:e1'].x).toBe(laneX(5));
-    expect(pos[ROOT_OUT_ID].x).toBe(laneX(7)); // 未分级列之后为总回
+    expect(pos['point:a1'].x).toBe(laneX(2)); // 总进(0) lv0(1) lv1(2)
+    expect(pos['point:e1'].x).toBe(laneX(6)); // lv5 在 lv0..lv6 列序中为第 6 列
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回
     expect(pos[ROOT_IN_ID].y).toBe(centerY);
     expect(pos[ROOT_IN_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
   });
   });
@@ -245,7 +268,7 @@ describe('computeLayout 与排序集成', () => {
       return r;
       return r;
     };
     };
     let manual = 0;
     let manual = 0;
-    for (let i = 1; i <= 4; i++) {
+    for (let i = 0; i <= 5; i++) {
       const es = edges.get(`${i}-${i + 1}`);
       const es = edges.get(`${i}-${i + 1}`);
       if (!es) continue;
       if (!es) continue;
       manual += countCrossings(rankOf(String(i)), rankOf(String(i + 1)), es);
       manual += countCrossings(rankOf(String(i)), rankOf(String(i + 1)), es);