zhangwencui
3 小时以前 4c8d18cc5ed8a7b0e220c91a858d16d0310896df
BOM新增修改删除功能开发、以及BOM结构的可编辑
已修改4个文件
947 ■■■■■ 文件已修改
src/api/productionManagement/bom.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/BomStructureItem.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/index.vue 365 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/structure.vue 534 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/bom.js
@@ -48,7 +48,7 @@
// 获取产品列表 (用于新增BOM时选择产品)
export function getProductList(query) {
  return request({
    url: "/product/ledger/listPage",
    url: "/basic/product/pageModel",
    method: "get",
    params: query,
  });
@@ -67,7 +67,7 @@
// 保存 BOM 结构
export function addStructure(data) {
  return request({
    url: "/technologyBomStructure/batchSave",
    url: "/technologyBomStructure",
    method: "post",
    data: data,
  });
src/pages/productionDesign/bom/BomStructureItem.vue
@@ -58,23 +58,39 @@
            </view>
          </view>
        </view>
        <view v-if="isEdit"
              class="item-actions">
          <up-button size="mini"
                     type="primary"
                     @click.stop="emitEdit">编辑</up-button>
          <up-button size="mini"
                     type="success"
                     @click.stop="emitAddChild">添加</up-button>
          <up-button size="mini"
                     type="error"
                     @click.stop="emitRemove">删除</up-button>
        </view>
      </view>
    </view>
    <!-- 递归展示子节点 -->
    <view v-if="hasChildren && isExpanded"
          class="children-container">
      <BomStructureItem v-for="(child, index) in item.children"
                        :key="index"
                        :key="child.tempId || child.id || index"
                        :item="child"
                        :level="level + 1"
                        :isLast="index === item.children.length - 1"
                        :processOptions="processOptions" />
                        :processOptions="processOptions"
                        :isEdit="isEdit"
                        @edit="$emit('edit', $event)"
                        @add-child="$emit('add-child', $event)"
                        @remove="$emit('remove', $event)" />
    </view>
  </view>
</template>
<script setup>
  import { ref, computed, defineProps } from "vue";
  import { ref, computed, defineProps, defineEmits } from "vue";
  const props = defineProps({
    item: {
@@ -93,7 +109,12 @@
      type: Array,
      default: () => [],
    },
    isEdit: {
      type: Boolean,
      default: false,
    },
  });
  const emit = defineEmits(["edit", "add-child", "remove"]);
  const isExpanded = ref(true);
  const hasChildren = computed(
@@ -109,6 +130,16 @@
  const getProcessName = id => {
    const process = props.processOptions.find(p => p.id === id);
    return process ? process.name : "-";
  };
  const emitEdit = () => {
    emit("edit", props.item);
  };
  const emitAddChild = () => {
    emit("add-child", props.item);
  };
  const emitRemove = () => {
    emit("remove", props.item);
  };
</script>
@@ -253,4 +284,11 @@
  .children-container {
    position: relative;
  }
  .item-actions {
    display: flex;
    justify-content: flex-end;
    gap: 16rpx;
    margin-top: 16rpx;
  }
</style>
src/pages/productionDesign/bom/index.vue
@@ -57,6 +57,14 @@
                     size="small"
                     type="primary"
                     @click="goStructure(item)">查看详情</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="warning"
                     @click="openEdit(item)">修改</up-button>
          <up-button class="action-btn"
                     size="small"
                     type="error"
                     @click="handleDelete(item)">删除</up-button>
        </view>
      </view>
      <up-loadmore :status="pageStatus" />
@@ -66,13 +74,119 @@
      <up-empty text="暂无BOM数据"
                mode="list"></up-empty>
    </view>
    <view class="fab-button"
          @click="openAdd">
      <up-icon name="plus"
               size="24"
               color="#ffffff"></up-icon>
    </view>
    <up-popup :show="showFormPopup"
              mode="bottom"
              round
              @close="closeFormPopup">
      <view class="popup-container">
        <view class="popup-header">
          <text class="popup-cancel"
                @click="closeFormPopup">取消</text>
          <text class="popup-title">{{ formMode === 'add' ? '新增BOM' : '修改BOM' }}</text>
          <text class="popup-confirm"
                @click="submitForm">确定</text>
        </view>
        <view class="popup-body">
          <up-form ref="bomFormRef"
                   :model="bomForm"
                   :rules="bomRules"
                   label-width="110">
            <up-form-item label="产品"
                          prop="productModelId"
                          required>
              <up-input v-model="bomForm.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="bomForm.productModelName"
                        readonly
                        placeholder="--" />
            </up-form-item>
            <up-form-item label="版本号"
                          prop="version"
                          required>
              <up-input v-model="bomForm.version"
                        placeholder="请输入版本号"
                        clearable />
            </up-form-item>
            <up-form-item label="备注"
                          prop="remark">
              <up-textarea v-model="bomForm.remark"
                           placeholder="请输入备注"
                           auto-height />
            </up-form-item>
          </up-form>
        </view>
      </view>
    </up-popup>
    <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>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onReachBottom, onShow } from "@dcloudio/uni-app";
  import { listPage } from "@/api/productionManagement/bom";
  import {
    listPage,
    add,
    update,
    batchDelete,
    getProductList,
  } from "@/api/productionManagement/bom";
  const queryParams = reactive({
    productName: "",
@@ -85,6 +199,34 @@
    size: 3,
    total: 0,
  });
  const showFormPopup = ref(false);
  const formMode = ref("add");
  const bomFormRef = ref(null);
  const bomForm = reactive({
    id: undefined,
    productName: "",
    productModelName: "",
    productModelId: "",
    remark: "",
    version: "",
  });
  const bomRules = {
    productModelId: [{ required: true, message: "请选择产品", trigger: "blur" }],
    version: [{ required: true, message: "请输入版本号", trigger: "blur" }],
  };
  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();
@@ -144,6 +286,153 @@
    });
  };
  const openAdd = () => {
    formMode.value = "add";
    Object.assign(bomForm, {
      id: undefined,
      productName: "",
      productModelName: "",
      productModelId: "",
      remark: "",
      version: "",
    });
    showFormPopup.value = true;
  };
  const openEdit = row => {
    formMode.value = "edit";
    Object.assign(bomForm, {
      id: row.id,
      productName: row.productName || "",
      productModelName: row.productModelName || "",
      productModelId: row.productModelId || "",
      remark: row.remark || "",
      version: row.version || "",
    });
    showFormPopup.value = true;
  };
  const closeFormPopup = () => {
    showFormPopup.value = false;
  };
  const submitForm = () => {
    if (!bomFormRef.value) return;
    bomFormRef.value.validate(valid => {
      if (!valid) return;
      const payload = { ...bomForm };
      const req = formMode.value === "add" ? add(payload) : update(payload);
      req
        .then(res => {
          if (res && res.code !== undefined && res.code !== 200) {
            uni.showToast({
              title: res.msg || "提交失败",
              icon: "none",
            });
            return;
          }
          uni.showToast({
            title: "提交成功",
            icon: "success",
          });
          closeFormPopup();
          handleSearch();
        })
        .catch(() => {
          uni.showToast({
            title: "提交失败",
            icon: "error",
          });
        });
    });
  };
  const handleDelete = row => {
    if (!row?.id) return;
    uni.showModal({
      title: "提示",
      content: "确认删除该BOM?",
      confirmText: "确认",
      cancelText: "取消",
      success: res => {
        if (!res.confirm) return;
        batchDelete([row.id])
          .then(result => {
            if (result && result.code !== undefined && result.code !== 200) {
              uni.showToast({
                title: result.msg || "删除失败",
                icon: "none",
              });
              return;
            }
            uni.showToast({
              title: "删除成功",
              icon: "success",
            });
            handleSearch();
          })
          .catch(() => {
            uni.showToast({
              title: "删除失败",
              icon: "error",
            });
          });
      },
    });
  };
  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 => {
    bomForm.productModelId = row.id;
    bomForm.productName = row.productName || "";
    bomForm.productModelName = row.model || "";
    showProductPicker.value = false;
  };
  onReachBottom(() => {
    getList();
  });
@@ -176,4 +465,78 @@
    margin: 0 !important;
    margin-bottom: 15rpx !important;
  }
  .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>
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>