gaoluyang
6 小时以前 2fd817ab50faa08111ce6e64c6d22a54807d08a4
pro
1.bom编辑页面样式优化
已添加2个文件
841 ■■■■■ 文件已修改
src/views/productionManagement/productStructure/DetailNew/MaterialCard.vue 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/DetailNew/index.vue 617 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/DetailNew/MaterialCard.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,224 @@
<template>
  <div class="material-node">
    <!-- å½“前节点卡片 -->
    <div :class="['node-card', isRoot ? 'root-card' : 'child-card']">
      <div class="node-header">
        <div class="node-label">
          <el-tag :type="isRoot ? '' : 'success'" size="small" effect="dark">
            {{ isRoot ? '成品' : '原料' }}
          </el-tag>
          <span class="node-title">{{ row.productName || '未选择产品' }}</span>
          <span v-if="row.model" class="node-sub">规格: {{ row.model }}</span>
          <span v-if="row.unit" class="node-sub">单位: {{ row.unit }}</span>
        </div>
        <div class="node-actions">
          <el-button v-if="editable"
                     type="primary"
                     text
                     size="small"
                     @click="handleAdd">
            + æ·»åŠ {{ isRoot ? '原料' : '子级原料' }}
          </el-button>
          <el-button v-if="editable"
                     type="danger"
                     text
                     size="small"
                     @click="$emit('remove', row.tempId)">
            åˆ é™¤
          </el-button>
        </div>
      </div>
      <!-- ç¼–辑模式下的表单 -->
      <div v-if="editable" class="node-body">
        <el-row :gutter="12">
          <el-col :span="7">
            <el-form-item label="产品" :rules="[{ required: true, message: '请选择产品' }]" style="margin:0">
              <el-input :model-value="row.productName || ''"
                        readonly
                        placeholder="点击选择产品"
                        @click="openSelect"
                        style="width:100%">
                <template #suffix>
                  <el-icon><component :is="SearchIcon" /></el-icon>
                </template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="5">
            <el-form-item label="规格" style="margin:0">
              <el-select v-model="row.model"
                         placeholder="请选择规格"
                         clearable
                         style="width:100%"
                         @visible-change="(v:boolean) => { if (v) openSelect() }">
                <el-option v-if="row.model" :label="row.model" :value="row.model" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col v-if="!isRoot" :span="5">
            <el-form-item label="工序" :rules="[{ required: true, message: '请选择工序' }]" style="margin:0">
              <el-select v-model="row.processId"
                         placeholder="请选择"
                         filterable
                         clearable
                         style="width:100%"
                         @change="(v:any) => $emit('processChange', row, v)">
                <el-option v-for="item in processOptions"
                           :key="item.id"
                           :label="item.name"
                           :value="item.id" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="4">
            <el-form-item label="数量" :rules="[{ required: true, message: '请填写数量' }]" style="margin:0">
              <el-input-number v-model="row.unitQuantity"
                               :min="0"
                               :precision="2"
                               :step="1"
                               controls-position="right"
                               style="width:100%"
                               @change="$emit('quantityChange')" />
            </el-form-item>
          </el-col>
          <el-col :span="3">
            <el-form-item label="单位" style="margin:0">
              <el-input v-model="row.unit" placeholder="单位" clearable style="width:100%" />
            </el-form-item>
          </el-col>
        </el-row>
      </div>
      <!-- éžç¼–辑模式:简洁显示 -->
      <div v-else class="node-view">
        <span v-if="!isRoot && row.processName">工序: {{ row.processName }} | </span>
        <span>数量: {{ row.unitQuantity || '-' }}</span>
      </div>
    </div>
    <!-- é€’归渲染子节点 -->
    <div v-if="row.children && row.children.length > 0" class="node-children">
      <MaterialCard
        v-for="child in row.children"
        :key="child.tempId"
        :row="child"
        :depth="depth + 1"
        :editable="editable"
        :process-options="processOptions"
        @remove="(id:string) => $emit('remove', id)"
        @add="(id:string) => $emit('add', id)"
        @select-product="(tempId: string, data: any) => $emit('selectProduct', tempId, data)"
        @process-change="(row: any, v: any) => $emit('processChange', row, v)"
        @quantity-change="$emit('quantityChange')"
      />
    </div>
  </div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Search } from '@element-plus/icons-vue'
const SearchIcon = Search
const props = defineProps<{
  row: any
  depth: number
  editable: boolean
  processOptions: any[]
}>()
const emit = defineEmits<{
  remove: [tempId: string]
  add: [tempId: string]
  selectProduct: [tempId: string, data: any]
  processChange: [row: any, value: any]
  quantityChange: []
}>()
const isRoot = computed(() => props.depth === 0)
const openSelect = () => {
  emit('selectProduct', props.row.tempId, null)
}
const handleAdd = () => {
  emit('add', props.row.tempId)
}
</script>
<script lang="ts">
export default { name: 'MaterialCard' }
</script>
<style scoped>
.material-node {
  margin: 4px 0;
}
.node-card {
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  overflow: hidden;
}
.root-card {
  border-color: #409eff;
  border-left: 4px solid #409eff;
  background-color: #f0f5ff;
}
.child-card {
  border-left: 4px solid #67c23a;
  background-color: #f0f9eb;
}
.node-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  background-color: rgba(0,0,0,0.03);
  flex-wrap: wrap;
  gap: 4px;
}
.node-label {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.node-title {
  font-weight: 600;
  color: #303133;
}
.node-sub {
  font-size: 12px;
  color: #909399;
}
.node-actions {
  display: flex;
  gap: 4px;
}
.node-body {
  padding: 10px 12px;
}
.node-view {
  padding: 6px 12px;
  font-size: 13px;
  color: #606266;
}
.node-children {
  margin-left: 36px;
  padding-left: 16px;
  border-left: 2px dashed #dcdfe6;
}
</style>
src/views/productionManagement/productStructure/DetailNew/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,617 @@
<template>
  <div class="app-container">
    <PageHeader content="产品结构详情">
      <template #right-button>
        <el-button v-if="!dataValue.isEdit && !isOrderPage"
                   type="primary"
                   @click="dataValue.isEdit = true">编辑
        </el-button>
        <el-button v-if="dataValue.isEdit && !isOrderPage"
                   type="primary"
                   @click="cancelEdit">取消
        </el-button>
        <el-button v-if="!isOrderPage"
                   type="primary"
                   :loading="dataValue.loading"
                   @click="submit"
                   :disabled="!dataValue.isEdit">确认
        </el-button>
      </template>
    </PageHeader>
    <el-table :data="tableData"
              border
              :preserve-expanded-content="false"
              :default-expand-all="true"
              style="width: 100%">
      <el-table-column type="expand">
        <template #default="props">
          <el-form ref="form" :model="dataValue">
            <div class="tree-container">
              <div class="tree-legend">
                <el-tag type="" size="small" effect="dark">成品</el-tag>
                <span style="margin:0 4px">← æœ€ä¸Šå±‚(产出物)</span>
                <el-divider direction="vertical" />
                <span style="margin:0 4px">最下层(投入物)→</span>
                <el-tag type="success" size="small" effect="dark">原料</el-tag>
              </div>
              <div v-if="dataValue.dataList.length === 0 && dataValue.isEdit" class="empty-hint">
                è¯·ç‚¹å‡»ä¸‹æ–¹æŒ‰é’®æ·»åŠ æˆå“
              </div>
              <MaterialCard
                v-for="(item, index) in dataValue.dataList"
                :key="item.tempId"
                :row="item"
                :depth="0"
                :editable="dataValue.isEdit"
                :process-options="dataValue.processOptions"
                @remove="(id: string) => removeItem(id)"
                @add="(id: string) => addChildItem(id)"
                @select-product="(tempId: string, _data: any) => { dataValue.currentRowName = tempId; dataValue.showProductDialog = true }"
                @process-change="(row: any, v: any) => handleProcessChange(row, v)"
                @quantity-change="handleUnitQuantityChange"
              />
              <el-button v-if="dataValue.isEdit"
                         type="primary"
                         plain
                         style="margin-top:12px"
                         @click="addRootItem">
                + æ·»åŠ æˆå“
              </el-button>
            </div>
          </el-form>
        </template>
      </el-table-column>
      <el-table-column label="BOM编号"
                       prop="bomNo" />
      <el-table-column label="产品名称"
                       prop="productName" />
      <el-table-column label="规格型号"
                       prop="model" />
    </el-table>
    <product-select-dialog v-if="dataValue.showProductDialog"
                           v-model:model-value="dataValue.showProductDialog"
                           :single="true"
                           @confirm="handleProduct" />
  </div>
</template>
<script setup lang="ts">
  import {
    computed,
    defineAsyncComponent,
    defineComponent,
    onMounted,
    reactive,
    ref,
  } from "vue";
  import {
    queryList,
    addBomDetail,
  } from "@/api/productionManagement/productStructure.js";
  import { listProcessBom } from "@/api/productionManagement/productionOrder.js";
  import { list } from "@/api/productionManagement/productionProcess";
  import { ElMessage } from "element-plus";
  import { useRoute, useRouter } from "vue-router";
  defineComponent({
    name: "StructureEdit",
  });
  const ProductSelectDialog = defineAsyncComponent(
    () => import("@/views/basicData/product/ProductSelectDialog.vue")
  );
  import MaterialCard from "./MaterialCard.vue";
  const emit = defineEmits(["update:router"]);
  const form = ref();
  const route = useRoute();
  const router = useRouter();
  const routeId = computed({
    get() {
      return route.query.id;
    },
    set(val) {
      emit("update:router", val);
    },
  });
  // ä»Žè·¯ç”±å‚数获取产品信息
  const routeBomNo = computed(() => route.query.bomNo || "");
  const routeProductName = computed(() => route.query.productName || "");
  const routeProductModelName = computed(
    () => route.query.productModelName || ""
  );
  const routeOrderId = computed(() => route.query.orderId);
  const pageType = computed(() => route.query.type);
  const isOrderPage = computed(
    () => pageType.value === "order" && routeOrderId.value
  );
  const dataValue = reactive({
    dataList: [],
    productOptions: [],
    processOptions: [],
    showProductDialog: false,
    currentRowIndex: null,
    currentRowName: 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(2));
  };
  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);
  };
  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 handleUnitQuantityChange = () => {
    recalculateDemandedQuantities();
  };
  const tableData = reactive([
    {
      productName: "",
      model: "",
      bomNo: "",
    },
  ]);
  const openDialog = (tempId: any) => {
    console.log(tempId, "tempId");
    dataValue.currentRowName = tempId;
    dataValue.showProductDialog = true;
  };
  const fetchData = async () => {
    if (isOrderPage.value) {
      // è®¢å•情况:使用订单的产品结构接口
      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) || [];
      console.log(dataValue);
      normalizeTreeData(dataValue.dataList);
      console.log(dataValue.dataList, "dataValue.dataList");
    }
  };
  const fetchProcessOptions = async () => {
    const { data } = await list({});
    console.log(data, "dataValue.dataList");
    dataValue.processOptions = normalizeListData(data);
  };
  const handleProduct = (row: any) => {
    if (!Array.isArray(row) || row.length === 0) {
      ElMessage.warning("请选择一个产品");
      return;
    }
    // åªå…è®¸ä¸€ä¸ªï¼šå¦‚果上游返回了多个,默认使用最后一次选择并覆盖当前值
    const productData = row[row.length - 1];
    //  æœ€å¤–层组件中,与当前产品相同的产品只能有一个
    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
      ) {
        //  æŸ¥æ‰¾æ˜¯å¦å·²ç»æœ‰å…¶ä»–顶层行已经是这个产品
        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("最外层和当前产品一样的一级只能有一个");
          return;
        }
      }
    }
    // dataValue.dataList[dataValue.currentRowIndex].productName =
    //   row[0].productName;
    // dataValue.dataList[dataValue.currentRowIndex].model = row[0].model;
    // dataValue.dataList[dataValue.currentRowIndex].productModelId = row[0].id;
    // dataValue.dataList[dataValue.currentRowIndex].unit = row[0].unit || "";
    dataValue.dataList.map(item => {
      if (item.tempId === dataValue.currentRowName) {
        item.productName = productData.productName;
        item.model = productData.model;
        item.productModelId = productData.id;
        item.unit = productData.unit || "";
        return;
      }
      childItem(item, dataValue.currentRowName, productData);
    });
    dataValue.showProductDialog = false;
  };
  const childItem = (item: any, tempId: any, productData: any) => {
    if (item.tempId === tempId) {
      item.productName = productData.productName;
      item.model = productData.model;
      item.productModelId = productData.id;
      item.unit = productData.unit || "";
      return true;
    }
    if (item.children && item.children.length > 0) {
      for (let child of item.children) {
        if (childItem(child, tempId, productData)) {
          return true;
        }
      }
    }
    return false;
  };
  // é€’归校验所有层级的表单数据
  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("请选择规格");
        isValid = false;
        return;
      }
      if (!isTopLevel && !item.processId) {
        ElMessage.error("请选择消耗工序");
        isValid = false;
        return;
      }
      if (!item.unitQuantity) {
        ElMessage.error("请输入单位产出所需数量");
        isValid = false;
        return;
      }
      if (isOrderPage.value && !item.demandedQuantity) {
        ElMessage.error("请输入需求总量");
        isValid = false;
        return;
      }
      // if (!item.unit) {
      //   ElMessage.error("请输入单位");
      //   isValid = false;
      //   return;
      // }
      // é€’归校验子项字段
      if (item.children && item.children.length > 0) {
        item.children.forEach(child => {
          validateItem(child, false);
        });
      }
    };
    // 1. é¦–先校验同一父级下的同层消耗工序是否唯一
    checkProcessUniqueness(dataValue.dataList);
    if (!isValid) return false;
    // 2. ç„¶åŽéåŽ†æ ¡éªŒæ‰€æœ‰é¡¶å±‚é¡¹çš„å­—æ®µå¿…å¡«æƒ…å†µ
    dataValue.dataList.forEach(item => {
      validateItem(item, true);
    });
    return isValid;
  };
  const submit = () => {
    dataValue.loading = true;
    normalizeTreeData(dataValue.dataList);
    recalculateDemandedQuantities();
    // å…ˆè¿›è¡Œè¡¨å•校验
    const valid = validateAll();
    console.log(dataValue.dataList, "dataValue.dataList");
    if (valid) {
      addBomDetail({
        bomId: routeId.value,
        children: buildSubmitTree(dataValue.dataList || []),
      })
        .then(res => {
          router.go(-1);
          ElMessage.success("保存成功");
          dataValue.loading = false;
        })
        .catch(() => {
          dataValue.loading = false;
        });
    } else {
      dataValue.loading = false;
    }
  };
  const removeItem = (tempId: string) => {
    const topIndex = dataValue.dataList.findIndex(item => item.tempId === tempId);
    if (topIndex !== -1) {
      dataValue.dataList.splice(topIndex, 1);
      return;
    }
    const delchildItem = (items: any[], tempId: any) => {
      for (let i = 0; i < items.length; i++) {
        const item = items[i];
        if (item.tempId === tempId) {
          items.splice(i, 1);
          return true;
        }
        if (item.children && item.children.length > 0) {
          if (delchildItem(item.children, tempId)) {
            return true;
          }
        }
      }
      return false;
    };
    dataValue.dataList.forEach(item => {
      if (item.children && item.children.length > 0) {
        delchildItem(item.children, tempId);
      }
    });
  };
  const newChildNode = (parentItem: any) => ({
    parentId: parentItem.id || "",
    parentTempId: parentItem.tempId || "",
    productName: "",
    productId: "",
    model: undefined,
    productModelId: undefined,
    processId: "",
    processName: "",
    operationId: "",
    operationName: "",
    unitQuantity: 1,
    demandedQuantity: 0,
    unit: "",
    children: [],
    tempId: new Date().getTime(),
  });
  const addRootItem = () => {
    dataValue.dataList.push(newChildNode({ id: "", tempId: "" }));
  };
  const addChildItem = (parentTempId: string) => {
    const addToItem = (items: any[]): boolean => {
      for (const item of items) {
        if (item.tempId === parentTempId) {
          if (!item.children) item.children = [];
          item.children.push(newChildNode(item));
          recalculateDemandedQuantities();
          return true;
        }
        if (item.children?.length > 0) {
          if (addToItem(item.children)) return true;
        }
      }
      return false;
    };
    addToItem(dataValue.dataList);
  };
  const getPropPath = (row, field) => {
    // ä¸ºæ¯ä¸ªrow生成唯一的路径
    // ä½¿ç”¨row.id或索引作为唯一标识
    let path = "dataList";
    // ç®€å•实现:使用row的id或一个唯一标识
    const uniqueId = row.id || Math.floor(Math.random() * 10000);
    path += `.${uniqueId}`;
    return path + `.${field}`;
  };
  const cancelEdit = () => {
    dataValue.isEdit = false;
    // dataValue.dataList = dataValue.dataList.filter(item => item.id !== undefined);
    fetchData();
  };
  onMounted(async () => {
    // ä»Žè·¯ç”±å‚数回显数据
    tableData[0].productName = routeProductName.value as string;
    tableData[0].model = routeProductModelName.value as string;
    tableData[0].bomNo = routeBomNo.value as string;
    // è®¢å•情况下禁用编辑
    if (isOrderPage.value) {
      dataValue.isEdit = false;
    }
    // å…ˆåŠ è½½å·¥åºé€‰é¡¹ï¼Œå†åŠ è½½æ•°æ®ï¼Œç¡®ä¿el-select能够正确回显
    await fetchProcessOptions();
    await fetchData();
  });
</script>
<style scoped>
.tree-container {
  padding: 8px 0;
}
.tree-legend {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
  padding: 8px 12px;
  background: #f5f7fa;
  border-radius: 6px;
  font-size: 13px;
  color: #606266;
}
.empty-hint {
  text-align: center;
  color: #909399;
  padding: 24px 0;
}
</style>