zhangwencui
8 小时以前 4c8d18cc5ed8a7b0e220c91a858d16d0310896df
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>