From 4c8d18cc5ed8a7b0e220c91a858d16d0310896df Mon Sep 17 00:00:00 2001
From: zhangwencui <1064582902@qq.com>
Date: 星期一, 29 六月 2026 16:50:03 +0800
Subject: [PATCH] BOM新增修改删除功能开发、以及BOM结构的可编辑

---
 src/pages/productionDesign/bom/structure.vue |  534 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 530 insertions(+), 4 deletions(-)

diff --git a/src/pages/productionDesign/bom/structure.vue b/src/pages/productionDesign/bom/structure.vue
index 5b7c2a7..9fe18a4 100644
--- a/src/pages/productionDesign/bom/structure.vue
+++ b/src/pages/productionDesign/bom/structure.vue
@@ -11,16 +11,148 @@
     <view class="structure-list"
           v-if="dataList.length > 0">
       <BomStructureItem v-for="(item, index) in dataList"
-                        :key="index"
+                        :key="item.tempId || item.id || index"
                         :item="item"
                         :level="0"
                         :isLast="index === dataList.length - 1"
-                        :processOptions="processOptions" />
+                        :processOptions="processOptions"
+                        :isEdit="isEdit"
+                        @edit="openNodeEditor"
+                        @add-child="addChild"
+                        @remove="removeNode" />
     </view>
     <view v-else
           class="no-data">
       <up-empty text="鏆傛棤缁撴瀯鏁版嵁"
                 mode="list"></up-empty>
+    </view>
+    <!-- <view v-if="isEdit"
+          class="fab-button"
+          @click="addRoot">
+      <up-icon name="plus"
+               size="24"
+               color="#ffffff"></up-icon>
+    </view> -->
+    <up-popup :show="showNodePopup"
+              mode="bottom"
+              round
+              @close="closeNodeEditor">
+      <view class="popup-container">
+        <view class="popup-header">
+          <text class="popup-cancel"
+                @click="closeNodeEditor">鍙栨秷</text>
+          <text class="popup-title">缂栬緫鑺傜偣</text>
+          <text class="popup-confirm"
+                @click="confirmNodeEdit">纭畾</text>
+        </view>
+        <view class="popup-body">
+          <up-form :model="nodeForm"
+                   label-width="110">
+            <up-form-item label="浜у搧"
+                          required>
+              <up-input v-model="nodeForm.productName"
+                        readonly
+                        placeholder="鐐瑰嚮閫夋嫨浜у搧"
+                        @click="openProductPicker" />
+              <template #right>
+                <up-icon name="arrow-right"
+                         @click="openProductPicker"></up-icon>
+              </template>
+            </up-form-item>
+            <up-form-item label="瑙勬牸鍨嬪彿">
+              <up-input v-model="nodeForm.model"
+                        readonly
+                        placeholder="--" />
+            </up-form-item>
+            <up-form-item label="娑堣�楀伐搴�">
+              <up-input v-model="nodeForm.processName"
+                        readonly
+                        placeholder="鐐瑰嚮閫夋嫨宸ュ簭"
+                        @click="showProcessPicker = true" />
+              <template #right>
+                <up-icon name="arrow-right"
+                         @click="showProcessPicker = true"></up-icon>
+              </template>
+            </up-form-item>
+            <up-form-item label="鍗曚綅浜у嚭鎵�闇�鏁伴噺">
+              <up-input v-model="nodeForm.unitQuantity"
+                        type="number"
+                        placeholder="璇疯緭鍏�" />
+            </up-form-item>
+            <up-form-item label="鍗曚綅">
+              <up-input v-model="nodeForm.unit"
+                        placeholder="璇疯緭鍏�"
+                        clearable />
+            </up-form-item>
+            <up-form-item label="鐩樻暟">
+              <up-input v-model="nodeForm.diskQuantity"
+                        type="number"
+                        placeholder="璇疯緭鍏�" />
+            </up-form-item>
+          </up-form>
+        </view>
+      </view>
+    </up-popup>
+    <up-action-sheet :show="showProcessPicker"
+                     :actions="processActionList"
+                     title="閫夋嫨宸ュ簭"
+                     @select="onProcessSelect"
+                     @close="showProcessPicker = false" />
+    <up-popup :show="showProductPicker"
+              mode="bottom"
+              round
+              @close="showProductPicker = false">
+      <view class="popup-container">
+        <view class="popup-header">
+          <text class="popup-cancel"
+                @click="showProductPicker = false">鍙栨秷</text>
+          <text class="popup-title">閫夋嫨浜у搧</text>
+          <text class="popup-confirm"
+                @click="handleProductSearch">鎼滅储</text>
+        </view>
+        <view class="popup-body">
+          <view class="picker-search">
+            <up-input v-model="productQuery.productName"
+                      placeholder="浜у搧鍚嶇О"
+                      clearable
+                      @change="handleProductSearch" />
+            <up-input v-model="productQuery.model"
+                      placeholder="瑙勬牸鍨嬪彿"
+                      clearable
+                      @change="handleProductSearch" />
+          </view>
+          <scroll-view scroll-y
+                       class="picker-list"
+                       @scrolltolower="loadMoreProducts">
+            <view v-for="row in productList"
+                  :key="row.id"
+                  class="picker-item"
+                  @click="selectProduct(row)">
+              <view class="picker-item__title">
+                <text>{{ row.productName || '-' }}</text>
+              </view>
+              <view class="picker-item__sub">
+                <text>{{ row.model || '-' }}</text>
+                <text class="picker-item__unit">{{ row.unit || '-' }}</text>
+              </view>
+            </view>
+            <up-loadmore :status="productPageStatus" />
+          </scroll-view>
+        </view>
+      </view>
+    </up-popup>
+    <view class="bottom-actions">
+      <up-button v-if="!isEdit"
+                 type="primary"
+                 @click="startEdit">缂栬緫</up-button>
+      <template v-else>
+        <up-button class="bottom-actions__cancel"
+                   @click="cancelEdit">鍙栨秷</up-button>
+        <up-button class="bottom-actions__confirm"
+                   type="primary"
+                   :loading="saving"
+                   @click="saveEdit">纭</up-button>
+      </template>
     </view>
   </view>
 </template>
@@ -28,7 +160,11 @@
 <script setup>
   import { ref, reactive, computed } from "vue";
   import { onLoad } from "@dcloudio/uni-app";
-  import { queryStructureList } from "@/api/productionManagement/bom";
+  import {
+    queryStructureList,
+    addStructure,
+    getProductList,
+  } from "@/api/productionManagement/bom";
   import { list as getProcessList } from "@/api/productionManagement/processManagement";
   import BomStructureItem from "./BomStructureItem.vue";
 
@@ -36,25 +172,305 @@
   const bomNo = ref("");
   const productName = ref("");
   const dataList = ref([]);
+  const originalDataList = ref([]);
   const processOptions = ref([]);
+  const isEdit = ref(false);
+  const saving = ref(false);
+
+  const showNodePopup = ref(false);
+  const editingNode = ref(null);
+  const nodeForm = reactive({
+    tempId: "",
+    id: undefined,
+    productModelId: "",
+    productName: "",
+    model: "",
+    unit: "",
+    processId: "",
+    processName: "",
+    unitQuantity: "",
+    demandedQuantity: "",
+    diskQuantity: "",
+    children: [],
+  });
+  const showProcessPicker = ref(false);
+  const processActionList = computed(() => {
+    return (processOptions.value || []).map(p => ({
+      name: p.name,
+      value: p.id,
+    }));
+  });
+
+  const showProductPicker = ref(false);
+  const productQuery = reactive({
+    productName: "",
+    model: "",
+  });
+  const productList = ref([]);
+  const productPage = reactive({
+    current: 1,
+    size: 20,
+    total: 0,
+  });
+  const productPageStatus = ref("loadmore");
 
   const goBack = () => {
     uni.navigateBack();
   };
 
+  const genTempId = () => `${Date.now()}_${Math.random()}`;
+
+  const normalizeTreeData = items => {
+    if (!Array.isArray(items)) return;
+    items.forEach(item => {
+      item.tempId = item.tempId || item.id || genTempId();
+      const pid = item.processId ?? item.operationId ?? "";
+      item.processId = pid;
+      if (pid && !item.processName) {
+        const opt = processOptions.value.find(p => String(p.id) === String(pid));
+        item.processName = opt?.name || item.operationName || "";
+      }
+      if (Array.isArray(item.children) && item.children.length > 0) {
+        normalizeTreeData(item.children);
+      } else if (!Array.isArray(item.children)) {
+        item.children = [];
+      }
+    });
+  };
+
   const fetchData = () => {
     queryStructureList(bomId.value).then(res => {
       dataList.value = res.data || [];
+      normalizeTreeData(dataList.value);
+      originalDataList.value = JSON.parse(JSON.stringify(dataList.value || []));
     });
   };
 
   const fetchProcess = () => {
     getProcessList().then(res => {
       processOptions.value = res.data || [];
+      normalizeTreeData(dataList.value);
     });
   };
 
   const productModelName = ref("");
+
+  const startEdit = () => {
+    isEdit.value = true;
+    originalDataList.value = JSON.parse(JSON.stringify(dataList.value || []));
+  };
+
+  const cancelEdit = () => {
+    isEdit.value = false;
+    dataList.value = JSON.parse(JSON.stringify(originalDataList.value || []));
+    normalizeTreeData(dataList.value);
+  };
+
+  const buildSubmitTree = items => {
+    return (items || []).map(item => {
+      const processId = item.processId ?? item.operationId ?? "";
+      return {
+        id: item.id,
+        bomId: bomId.value,
+        productModelId: item.productModelId,
+        productName: item.productName,
+        model: item.model,
+        unit: item.unit,
+        processId: processId,
+        operationId: processId,
+        processName: item.processName || item.operationName || "",
+        operationName: item.processName || item.operationName || "",
+        unitQuantity: item.unitQuantity,
+        demandedQuantity: item.demandedQuantity,
+        diskQuantity: item.diskQuantity,
+        children: buildSubmitTree(item.children || []),
+      };
+    });
+  };
+
+  const saveEdit = () => {
+    if (!isEdit.value) return;
+    saving.value = true;
+    addStructure({
+      bomId: bomId.value,
+      children: buildSubmitTree(dataList.value || []),
+    })
+      .then(res => {
+        if (res && res.code !== undefined && res.code !== 200) {
+          uni.showToast({
+            title: res.msg || "淇濆瓨澶辫触",
+            icon: "none",
+          });
+          return;
+        }
+        uni.showToast({
+          title: "淇濆瓨鎴愬姛",
+          icon: "success",
+        });
+        isEdit.value = false;
+        fetchData();
+      })
+      .finally(() => {
+        saving.value = false;
+      });
+  };
+
+  const emptyNode = () => ({
+    tempId: genTempId(),
+    id: undefined,
+    productModelId: "",
+    productName: "",
+    model: "",
+    unit: "",
+    processId: "",
+    processName: "",
+    unitQuantity: "",
+    demandedQuantity: "",
+    diskQuantity: "",
+    children: [],
+  });
+
+  const addRoot = () => {
+    dataList.value = Array.isArray(dataList.value) ? dataList.value : [];
+    dataList.value.push(emptyNode());
+  };
+
+  const addChild = node => {
+    if (!node.children || !Array.isArray(node.children)) node.children = [];
+    node.children.push(emptyNode());
+  };
+
+  const removeByTempId = (items, tempId) => {
+    const idx = (items || []).findIndex(i => i.tempId === tempId);
+    if (idx !== -1) {
+      items.splice(idx, 1);
+      return true;
+    }
+    for (const it of items || []) {
+      if (Array.isArray(it.children) && it.children.length > 0) {
+        if (removeByTempId(it.children, tempId)) return true;
+      }
+    }
+    return false;
+  };
+
+  const removeNode = node => {
+    uni.showModal({
+      title: "鎻愮ず",
+      content: "纭鍒犻櫎璇ヨ妭鐐癸紵",
+      confirmText: "纭",
+      cancelText: "鍙栨秷",
+      success: res => {
+        if (!res.confirm) return;
+        removeByTempId(dataList.value, node.tempId);
+      },
+    });
+  };
+
+  const openNodeEditor = node => {
+    if (!isEdit.value) return;
+    editingNode.value = node;
+    Object.assign(nodeForm, {
+      tempId: node.tempId,
+      id: node.id,
+      productModelId: node.productModelId || "",
+      productName: node.productName || "",
+      model: node.model || "",
+      unit: node.unit || "",
+      processId: node.processId || "",
+      processName: node.processName || "",
+      unitQuantity: node.unitQuantity ?? "",
+      demandedQuantity: node.demandedQuantity ?? "",
+      diskQuantity: node.diskQuantity ?? "",
+      children: node.children || [],
+    });
+    showNodePopup.value = true;
+  };
+
+  const closeNodeEditor = () => {
+    showNodePopup.value = false;
+    editingNode.value = null;
+    showProcessPicker.value = false;
+  };
+
+  const confirmNodeEdit = () => {
+    if (!editingNode.value) {
+      closeNodeEditor();
+      return;
+    }
+    Object.assign(editingNode.value, {
+      productModelId: nodeForm.productModelId,
+      productName: nodeForm.productName,
+      model: nodeForm.model,
+      unit: nodeForm.unit,
+      processId: nodeForm.processId,
+      processName: nodeForm.processName,
+      unitQuantity: nodeForm.unitQuantity,
+      diskQuantity: nodeForm.diskQuantity,
+    });
+    closeNodeEditor();
+  };
+
+  const onProcessSelect = item => {
+    nodeForm.processId = item.value;
+    nodeForm.processName = item.name;
+    showProcessPicker.value = false;
+  };
+
+  const openProductPicker = () => {
+    showProductPicker.value = true;
+    handleProductSearch();
+  };
+
+  const handleProductSearch = () => {
+    productPage.current = 1;
+    productPageStatus.value = "loadmore";
+    productList.value = [];
+    loadMoreProducts();
+  };
+
+  const loadMoreProducts = () => {
+    if (
+      productPageStatus.value === "loading" ||
+      productPageStatus.value === "nomore"
+    ) {
+      return;
+    }
+    productPageStatus.value = "loading";
+    getProductList({
+      current: productPage.current,
+      size: productPage.size,
+      productName: productQuery.productName,
+      model: productQuery.model,
+    })
+      .then(res => {
+        const records = res?.data?.records || res?.records || res?.data || [];
+        const total = res?.data?.total || res?.total || 0;
+        const next = Array.isArray(records) ? records : [];
+        productList.value =
+          productPage.current === 1 ? next : [...productList.value, ...next];
+        productPage.total = Number(total || productList.value.length);
+        if (productList.value.length >= productPage.total) {
+          productPageStatus.value = "nomore";
+        } else {
+          productPageStatus.value = "loadmore";
+          productPage.current++;
+        }
+      })
+      .catch(() => {
+        productPageStatus.value = "loadmore";
+      });
+  };
+
+  const selectProduct = row => {
+    nodeForm.productModelId = row.id;
+    nodeForm.productName = row.productName || "";
+    nodeForm.model = row.model || "";
+    if (!nodeForm.unit) {
+      nodeForm.unit = row.unit || "";
+    }
+    showProductPicker.value = false;
+  };
 
   onLoad(options => {
     bomId.value = options.id;
@@ -70,7 +486,7 @@
   .structure-page {
     background-color: #f5f5f5;
     min-height: 100vh;
-    padding-bottom: 120rpx;
+    padding-bottom: 200rpx;
   }
 
   .info-card {
@@ -97,4 +513,114 @@
   .no-data {
     padding-top: 100rpx;
   }
+
+  .fab-button {
+    position: fixed;
+    right: 30rpx;
+    bottom: 150rpx;
+    width: 96rpx;
+    height: 96rpx;
+    border-radius: 48rpx;
+    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 10rpx 20rpx rgba(0, 108, 251, 0.25);
+    z-index: 1000;
+  }
+
+  .bottom-actions {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: #fff;
+    padding: 16rpx 24rpx;
+    display: flex;
+    gap: 16rpx;
+    box-shadow: 0 -6rpx 18rpx rgba(0, 0, 0, 0.06);
+    z-index: 1100;
+  }
+
+  .bottom-actions__cancel {
+    flex: 1;
+  }
+
+  .bottom-actions__confirm {
+    flex: 2;
+  }
+
+  .popup-container {
+    background: #fff;
+    border-radius: 20rpx 20rpx 0 0;
+    max-height: 80vh;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .popup-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 24rpx 28rpx;
+    border-bottom: 1rpx solid #f0f0f0;
+  }
+
+  .popup-title {
+    font-size: 30rpx;
+    font-weight: 600;
+    color: #333;
+  }
+
+  .popup-cancel {
+    font-size: 28rpx;
+    color: #666;
+  }
+
+  .popup-confirm {
+    font-size: 28rpx;
+    color: #006cfb;
+    font-weight: 600;
+  }
+
+  .popup-body {
+    padding: 20rpx 24rpx 30rpx;
+    overflow: hidden;
+    flex: 1;
+  }
+
+  .picker-search {
+    display: flex;
+    gap: 16rpx;
+    margin-bottom: 16rpx;
+  }
+
+  .picker-list {
+    height: 60vh;
+  }
+
+  .picker-item {
+    padding: 22rpx 0;
+    border-bottom: 1rpx solid #f5f5f5;
+  }
+
+  .picker-item__title {
+    font-size: 28rpx;
+    color: #333;
+    font-weight: 600;
+  }
+
+  .picker-item__sub {
+    margin-top: 6rpx;
+    font-size: 24rpx;
+    color: #666;
+    display: flex;
+    justify-content: space-between;
+    gap: 16rpx;
+  }
+
+  .picker-item__unit {
+    color: #999;
+    white-space: nowrap;
+  }
 </style>

--
Gitblit v1.9.3