gongchunyi
2026-09-18 4dee4cb5b31a7f1dcd2c7b3d72f74b78d2fe3f29
src/views/productionManagement/productStructure/Detail/index.vue
@@ -22,16 +22,34 @@
              border
              :preserve-expanded-content="false"
              :default-expand-all="true"
              style="width: 100%">
              style="width: 100%"
              @expand-change="handleOuterExpandChange">
      <el-table-column type="expand">
        <template #default="props">
          <el-form ref="form"
                   :model="dataValue">
            <el-table :data="dataValue.dataList"
            <div v-if="dataValue.isEdit"
                 class="drag-tip">
              <el-icon><Rank /></el-icon>
              <span>拖动手柄调整结构:同层级直接拖动排序;拖到某行正下方成为其子级;拖到列表最前成为一级</span>
            </div>
            <el-table ref="tableRef"
                      :data="dataValue.dataList"
                      row-key="tempId"
                      default-expand-all
                      :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
                      @expand-change="handleTreeExpandChange"
                      style="width: 100%">
              <el-table-column v-if="dataValue.isEdit"
                               width="44"
                               align="center">
                <template #default="{ row }">
                  <div class="drag-handle"
                       title="拖动调整顺序:拖到某行正下方成为其子级,拖到列表最前成为一级">
                    <el-icon><Rank /></el-icon>
                  </div>
                </template>
              </el-table-column>
              <el-table-column prop="productName"
                               label="产品" />
              <el-table-column prop="model"
@@ -59,17 +77,20 @@
                  <el-form-item v-if="dataValue.isEdit"
                                :rules="dataValue.dataList.some(item => (item as any).tempId === row.tempId) ? [] : [{ required: true, message: '请选择消耗工序', trigger: 'change' }]"
                                style="margin: 0">
                    <el-select v-model="row.processId"
                               placeholder="请选择"
                               filterable
                               clearable
                               style="width: 100%"
                               :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)">
                      <el-option v-for="item in dataValue.processOptions"
                                 :key="item.id"
                                 :label="item.name"
                                 :value="item.id" />
                    </el-select>
                    <el-input v-model="row.processName"
                              readonly
                              placeholder="请选择消耗工序"
                              clearable
                              style="width: 100%"
                              :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)"
                              @click="openProcessDialog(row, $event)"
                              @clear="clearProcess(row)">
                      <template #append>
                        <el-button icon="Search"
                                   :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)"
                                   @click="openProcessDialog(row)" />
                      </template>
                    </el-input>
                  </el-form-item>
                </template>
              </el-table-column>
@@ -81,10 +102,10 @@
                                style="margin: 0">
                    <el-input-number v-model="row.unitQuantity"
                                     :min="0"
                                     :precision="2"
                                     :step="1"
                                     controls-position="right"
                                     style="width: 100%"
                                     @change="handleUnitQuantityChange"
                                     :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)" />
                  </el-form-item>
                </template>
@@ -98,11 +119,10 @@
                                style="margin: 0">
                    <el-input-number v-model="row.demandedQuantity"
                                     :min="0"
                                     :precision="2"
                                     :step="1"
                                     controls-position="right"
                                     style="width: 100%"
                                     :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)" />
                                     :disabled="true" />
                  </el-form-item>
                </template>
              </el-table-column>
@@ -115,7 +135,7 @@
                    <el-input v-model="row.unit"
                              placeholder="请输入单位"
                              clearable
                               :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)" />
                              :disabled="!dataValue.isEdit || dataValue.dataList.some(item => (item as any).tempId === row.tempId)" />
                  </el-form-item>
                </template>
              </el-table-column>
@@ -148,7 +168,11 @@
    </el-table>
    <product-select-dialog v-if="dataValue.showProductDialog"
                           v-model:model-value="dataValue.showProductDialog"
                           :single="true"
                           @confirm="handleProduct" />
    <process-select-dialog v-if="dataValue.showProcessDialog"
                           v-model:model-value="dataValue.showProcessDialog"
                           @confirm="handleProcessConfirm" />
  </div>
</template>
@@ -157,15 +181,23 @@
    computed,
    defineAsyncComponent,
    defineComponent,
    nextTick,
    onBeforeUnmount,
    onMounted,
    reactive,
    ref,
    watch,
  } from "vue";
  import { queryList, add } from "@/api/productionManagement/productStructure.js";
  import {
    queryList,
    addBomDetail,
  } from "@/api/productionManagement/productStructure.js";
  import { listProcessBom } from "@/api/productionManagement/productionOrder.js";
  import { list } from "@/api/productionManagement/productionProcess";
  import { listAll } from "@/api/productionManagement/productionProcess";
  import { ElMessage } from "element-plus";
  import { useRoute, useRouter } from "vue-router";
  import Sortable from "sortablejs";
  import { Rank } from "@element-plus/icons-vue";
  defineComponent({
    name: "StructureEdit",
@@ -174,8 +206,16 @@
  const ProductSelectDialog = defineAsyncComponent(
    () => import("@/views/basicData/product/ProductSelectDialog.vue")
  );
  const ProcessSelectDialog = defineAsyncComponent(
    () =>
      import(
        "@/views/productionManagement/productionProcess/ProcessSelectDialog.vue"
      )
  );
  const emit = defineEmits(["update:router"]);
  const form = ref();
  const tableRef = ref();
  let tableSortable = null;
  const route = useRoute();
  const router = useRouter();
@@ -206,11 +246,325 @@
    productOptions: [],
    processOptions: [],
    showProductDialog: false,
    showProcessDialog: false,
    currentRowIndex: null,
    currentRowName: null,
    currentProcessRow: null,
    loading: false,
    isEdit: false,
  });
  const normalizeListData = (source: any) => {
    if (Array.isArray(source)) {
      return source;
    }
    if (Array.isArray(source?.records)) {
      return source.records;
    }
    return [];
  };
  const getProcessOptionById = (id: any) => {
    if (id === undefined || id === null || id === "") {
      return null;
    }
    return (
      normalizeListData(dataValue.processOptions).find(
        option => String(option.id) === String(id)
      ) || null
    );
  };
  const syncProcessOperationFields = (item: any) => {
    const processId = item.processId ?? item.operationId ?? "";
    if (!processId) {
      item.processId = "";
      item.operationId = "";
      item.processName = "";
      item.operationName = "";
      return;
    }
    const option = getProcessOptionById(processId);
    const processName =
      option?.name || item.processName || item.operationName || "";
    item.processId = processId;
    item.operationId = processId;
    item.processName = processName;
    item.operationName = processName;
  };
  const normalizeTreeData = (items: any[]) => {
    items.forEach((item: any) => {
      item.tempId = item.tempId || item.id || `${Date.now()}_${Math.random()}`;
      syncProcessOperationFields(item);
      if (Array.isArray(item.children) && item.children.length > 0) {
        normalizeTreeData(item.children);
      }
    });
  };
  const toQuantityNumber = (value: any) => {
    const numberValue = Number(value);
    if (!Number.isFinite(numberValue)) {
      return 0;
    }
    return Number(numberValue.toFixed(4));
  };
  const syncDemandedQuantityTree = (
    items: any[],
    parentDemandedQuantity: number | null = null
  ) => {
    items.forEach((item: any) => {
      if (parentDemandedQuantity !== null) {
        item.demandedQuantity = toQuantityNumber(
          parentDemandedQuantity * toQuantityNumber(item.unitQuantity)
        );
      }
      if (Array.isArray(item.children) && item.children.length > 0) {
        syncDemandedQuantityTree(
          item.children,
          toQuantityNumber(item.demandedQuantity)
        );
      }
    });
  };
  const recalculateDemandedQuantities = () => {
    if (!isOrderPage.value) {
      return;
    }
    syncDemandedQuantityTree(dataValue.dataList);
  };
  // ===== 拖拽排序 =====
  // 按当前 DOM 行顺序展开成扁平列表(编辑模式下强制全部展开,保证行顺序与数据一致)
  const buildFlatTree = () => {
    const flat = [];
    const walk = (items, depth, parentNode) => {
      items.forEach(node => {
        flat.push({ node, depth, parentArray: items, parentNode });
        if (Array.isArray(node.children) && node.children.length > 0) {
          walk(node.children, depth + 1, node);
        }
      });
    };
    walk(dataValue.dataList, 0, null);
    return flat;
  };
  const removeNodeByTempId = (items, tempId) => {
    for (let i = 0; i < items.length; i++) {
      if (String(items[i].tempId) === String(tempId)) {
        items.splice(i, 1);
        return true;
      }
      if (
        Array.isArray(items[i].children) &&
        removeNodeByTempId(items[i].children, tempId)
      ) {
        return true;
      }
    }
    return false;
  };
  const updateParentRef = (node, parent) => {
    node.parentTempId = parent?.tempId ?? "";
    node.parentId = parent?.id ?? "";
  };
  const handleSortEnd = evt => {
    const { oldIndex, newIndex } = evt;
    if (
      typeof oldIndex !== "number" ||
      typeof newIndex !== "number" ||
      oldIndex === newIndex
    ) {
      return;
    }
    const flat = buildFlatTree();
    if (oldIndex < 0 || oldIndex >= flat.length) return;
    const dragged = flat[oldIndex];
    // 拖放后位于被拖行上方一行的数据(按最终 DOM 顺序换算到原扁平列表)
    let aEntry = null;
    if (newIndex > oldIndex) {
      aEntry = flat[newIndex];
    } else if (newIndex > 0) {
      aEntry = flat[newIndex - 1];
    }
    if (!aEntry) {
      // 拖到列表最前 → 成为一级
      removeNodeByTempId(dataValue.dataList, dragged.node.tempId);
      dataValue.dataList.unshift(dragged.node);
      updateParentRef(dragged.node, null);
    } else if (aEntry.depth < dragged.depth) {
      // 拖到某行正下方 → 成为该行的子级
      removeNodeByTempId(dataValue.dataList, dragged.node.tempId);
      if (!aEntry.node.children) {
        aEntry.node.children = [];
      }
      aEntry.node.children.push(dragged.node);
      updateParentRef(dragged.node, aEntry.node);
    } else {
      // 同级插入:向上找第一个深度不超过被拖行的节点
      let i = flat.indexOf(aEntry);
      while (i >= 0 && flat[i].depth > dragged.depth) {
        i--;
      }
      const eEntry = flat[i];
      // 拖回自己的子树内 → 不处理(防止形成循环)
      if (!eEntry || eEntry.node.tempId === dragged.node.tempId) {
        return;
      }
      removeNodeByTempId(dataValue.dataList, dragged.node.tempId);
      const siblings = eEntry.parentArray;
      const idx = siblings.indexOf(eEntry.node);
      siblings.splice(idx + 1, 0, dragged.node);
      updateParentRef(dragged.node, eEntry.parentNode);
    }
    recalculateDemandedQuantities();
  };
  const initSortable = () => {
    if (!tableRef.value) return;
    const tbody = tableRef.value.$el.querySelector(".el-table__body tbody");
    if (!tbody) return;
    tableSortable?.destroy();
    tableSortable = new Sortable(tbody, {
      animation: 150,
      handle: ".drag-handle",
      disabled: !dataValue.isEdit,
      ghostClass: "sortable-ghost",
      onEnd: handleSortEnd,
    });
  };
  const expandAllTree = () => {
    const table = tableRef.value;
    if (!table) return;
    const walk = items => {
      items.forEach(item => {
        if (Array.isArray(item.children) && item.children.length > 0) {
          table.toggleRowExpansion(item, true);
          walk(item.children);
        }
      });
    };
    walk(dataValue.dataList);
  };
  // 编辑模式下不允许折叠,保证拖拽时 DOM 行顺序与 dataList 一致
  const handleTreeExpandChange = (row, expanded) => {
    if (dataValue.isEdit && expanded === false) {
      nextTick(() => {
        tableRef.value?.toggleRowExpansion(row, true);
      });
    }
  };
  // 外层行折叠后内容会销毁,重新展开时重新初始化拖拽
  const handleOuterExpandChange = (row, expandedRows) => {
    if (Array.isArray(expandedRows) && expandedRows.some(r => r === row)) {
      nextTick(() => initSortable());
    }
  };
  watch(
    () => dataValue.isEdit,
    val => {
      tableSortable?.option("disabled", !val);
      if (val) {
        nextTick(() => {
          initSortable();
          expandAllTree();
        });
      }
    }
  );
  const buildSubmitTree = (items: any[]) => {
    return items.map((item: any) => {
      const current = { ...item };
      syncProcessOperationFields(current);
      current.children = Array.isArray(current.children)
        ? buildSubmitTree(current.children)
        : [];
      return current;
    });
  };
  const findSiblings = (items: any[], tempId: string): any[] | null => {
    if (!items || items.length === 0) return null;
    // 检查当前层级
    if (items.some(item => item.tempId === tempId)) {
      return items;
    }
    // 递归查找子级
    for (const item of items) {
      if (item.children && item.children.length > 0) {
        const result = findSiblings(item.children, tempId);
        if (result) return result;
      }
    }
    return null;
  };
  const handleProcessChange = (row: any, value: any) => {
    row.processId = value || "";
    syncProcessOperationFields(row);
    // 检查同一层级是否已经有其他不同的工序被选中
    const siblings = findSiblings(dataValue.dataList, row.tempId);
    if (siblings && value) {
      const hasDifferentProcess = siblings.some(sibling => {
        return sibling.tempId !== row.tempId && sibling.processId && sibling.processId !== value;
      });
      if (hasDifferentProcess) {
        ElMessage.warning("同一层级已存在不同的工序,请先统一工序后再进行修改");
      }
    }
  };
  const openProcessDialog = (row: any, event?: Event) => {
    // 点击清除按钮时不弹窗
    const target = event?.target as HTMLElement | undefined;
    if (target && target.closest(".el-input__clear")) {
      return;
    }
    dataValue.currentProcessRow = row;
    dataValue.showProcessDialog = true;
  };
  const clearProcess = (row: any) => {
    row.processId = "";
    row.operationId = "";
    row.processName = "";
    row.operationName = "";
  };
  const handleProcessConfirm = (processRow: any) => {
    const row = dataValue.currentProcessRow;
    if (!row) {
      return;
    }
    row.processId = processRow.id;
    row.operationId = processRow.id;
    row.processName = processRow.name;
    row.operationName = processRow.name;
    handleProcessChange(row, processRow.id);
  };
  const handleUnitQuantityChange = () => {
    recalculateDemandedQuantities();
  };
  const tableData = reactive([
    {
@@ -231,48 +585,47 @@
      // 订单情况:使用订单的产品结构接口
      const { data } = await listProcessBom({ orderId: routeOrderId.value });
      dataValue.dataList = (data as any) || [];
      normalizeTreeData(dataValue.dataList);
      recalculateDemandedQuantities();
    } else {
      // 非订单情况:使用原来的接口
      const { data } = await queryList(routeId.value);
      dataValue.dataList = (data as any) || [];
      // 为所有项及其子项设置name属性
      const setNameRecursively = (items: any[]) => {
        items.forEach((item: any) => {
          item.tempId = item.id;
          item.processName =
            dataValue.processOptions.find(option => option.id === item.processId)
              ?.name || "";
          if (item.children && item.children.length > 0) {
            setNameRecursively(item.children);
          }
        });
      };
      setNameRecursively(dataValue.dataList);
      console.log(dataValue);
      normalizeTreeData(dataValue.dataList);
      console.log(dataValue.dataList, "dataValue.dataList");
    }
  };
  const fetchProcessOptions = async () => {
    const { data } = await list();
    dataValue.processOptions = data as any;
    const { data } = await listAll();
    console.log(data, "dataValue.dataList");
    dataValue.processOptions = normalizeListData(data);
  };
  const handleProduct = (row: any) => {
    if (row?.length > 1) {
      ElMessage.error("只能选择一个产品");
    if (!Array.isArray(row) || row.length === 0) {
      ElMessage.warning("请选择一个产品");
      return;
    }
    const productData = row[0];
    // 只允许一个:如果上游返回了多个,默认使用最后一次选择并覆盖当前值
    const productData = row[row.length - 1];
    //  最外层组件中,与当前产品相同的产品只能有一个
    const isTopLevel = dataValue.dataList.some(item => (item as any).tempId === dataValue.currentRowName);
    const isTopLevel = dataValue.dataList.some(
      item => (item as any).tempId === dataValue.currentRowName
    );
    if (isTopLevel) {
      if (productData.productName === tableData[0].productName &&
        productData.model === tableData[0].model) {
      if (
        productData.productName === tableData[0].productName &&
        productData.model === tableData[0].model
      ) {
        //  查找是否已经有其他顶层行已经是这个产品
        const hasOther = dataValue.dataList.some(item =>
          (item as any).tempId !== dataValue.currentRowName &&
          (item as any).productName === tableData[0].productName &&
          (item as any).model === tableData[0].model
        const hasOther = dataValue.dataList.some(
          item =>
            (item as any).tempId !== dataValue.currentRowName &&
            (item as any).productName === tableData[0].productName &&
            (item as any).model === tableData[0].model
        );
        if (hasOther) {
          ElMessage.warning("最外层和当前产品一样的一级只能有一个");
@@ -319,8 +672,41 @@
  const validateAll = () => {
    let isValid = true;
    // 校验一组兄弟节点的工序是否都相同
    const checkProcessUniqueness = (items: any[]) => {
      if (!items || items.length === 0 || !isValid) return;
      // 获取第一个非空的工序ID作为参考
      const firstProcessId = items.find(item => item.processId)?.processId;
      // 如果有工序ID,检查所有项是否都使用相同的工序
      if (firstProcessId) {
        for (const item of items) {
          if (item.processId && item.processId !== firstProcessId) {
            const option1 = getProcessOptionById(firstProcessId);
            const option2 = getProcessOptionById(item.processId);
            const processName1 = option1?.name || "未知工序";
            const processName2 = option2?.name || "未知工序";
            ElMessage.error(
              `当前层级下工序不一致,请使用相同的工序。存在「${processName1}」和「${processName2}」`
            );
            isValid = false;
            return;
          }
        }
      }
      // 递归校验子级的兄弟节点
      for (const item of items) {
        if (item.children && item.children.length > 0) {
          checkProcessUniqueness(item.children);
        }
      }
    };
    // 校验函数
    const validateItem = (item: any, isTopLevel = false) => {
      if (!isValid) return;
      // 校验当前项的必填字段
      if (!item.model) {
        ElMessage.error("请选择规格");
@@ -348,7 +734,7 @@
      //   return;
      // }
      // 递归校验子项
      // 递归校验子项字段
      if (item.children && item.children.length > 0) {
        item.children.forEach(child => {
          validateItem(child, false);
@@ -356,7 +742,11 @@
      }
    };
    // 遍历所有顶层项
    // 1. 首先校验同一父级下的同层消耗工序是否唯一
    checkProcessUniqueness(dataValue.dataList);
    if (!isValid) return false;
    // 2. 然后遍历校验所有顶层项的字段必填情况
    dataValue.dataList.forEach(item => {
      validateItem(item, true);
    });
@@ -366,19 +756,19 @@
  const submit = () => {
    dataValue.loading = true;
    normalizeTreeData(dataValue.dataList);
    recalculateDemandedQuantities();
    // 先进行表单校验
    const valid = validateAll();
    console.log(dataValue.dataList, "dataValue.dataList");
    if (valid) {
      add({
      addBomDetail({
        bomId: routeId.value,
        children: dataValue.dataList || [],
        children: buildSubmitTree(dataValue.dataList || []),
      })
        .then(res => {
          router.push({
            path: "/productionManagement/productionManagement/productStructure/index",
          });
          router.go(-1);
          ElMessage.success("保存成功");
          dataValue.loading = false;
        })
@@ -390,7 +780,7 @@
    }
  };
  const removeItem = (tempId:string) => {
  const removeItem = (tempId: string) => {
    // 先尝试从顶层删除
    const topIndex = dataValue.dataList.findIndex(item => item.tempId === tempId);
    if (topIndex !== -1) {
@@ -436,13 +826,16 @@
          productModelId: undefined,
          processId: "",
          processName: "",
          unitQuantity: 0,
          operationId: "",
          operationName: "",
          unitQuantity: 1,
          demandedQuantity: 0,
          unit: "",
          children: [],
          tempId: new Date().getTime(),
        });
        recalculateDemandedQuantities();
        return;
      }
      addchildItem(item, tempId);
@@ -462,12 +855,16 @@
        model: undefined,
        productModelId: undefined,
        processId: "",
        unitQuantity: 0,
        processName: "",
        operationId: "",
        operationName: "",
        unitQuantity: 1,
        demandedQuantity: 0,
        children: [],
        unit: "",
        tempId: new Date().getTime(),
      });
      recalculateDemandedQuantities();
      return true;
    }
    if (item.children && item.children.length > 0) {
@@ -512,5 +909,38 @@
    // 先加载工序选项,再加载数据,确保el-select能够正确回显
    await fetchProcessOptions();
    await fetchData();
    await nextTick();
    initSortable();
  });
</script>
  onBeforeUnmount(() => {
    tableSortable?.destroy();
    tableSortable = null;
  });
</script>
<style scoped>
  .drag-tip {
    display: flex;
    align-items: center;
    gap: 4px;
    margin-bottom: 8px;
    font-size: 12px;
    color: #909399;
  }
  .drag-handle {
    cursor: move;
    color: #909399;
    text-align: center;
  }
  .drag-handle:hover {
    color: var(--el-color-primary);
  }
  :deep(.sortable-ghost) {
    opacity: 0.6;
    background-color: #f5f7fa !important;
  }
</style>