zhangwencui
2026-04-28 d01f1f16cf677a10687fc0cfd593e576f445ba6e
Bom模块开发
已添加4个文件
已修改3个文件
650 ■■■■■ 文件已修改
src/api/productionManagement/bom.js 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/processManagement.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/BomStructureItem.vue 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/index.vue 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/structure.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/works.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/bom.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
import request from "@/utils/request";
// BOM åˆ—表分页查询
export function listPage(query) {
  return request({
    url: "/technologyBom/listPage",
    method: "get",
    params: query,
  });
}
// æ–°å¢ž BOM
export function add(data) {
  return request({
    url: "/technologyBom/add",
    method: "post",
    data: data,
  });
}
// ä¿®æ”¹ BOM
export function update(data) {
  return request({
    url: "/technologyBom/update",
    method: "put",
    data: data,
  });
}
// åˆ é™¤ BOM
export function batchDelete(ids) {
  return request({
    url: "/technologyBom/batchDelete",
    method: "delete",
    data: ids,
  });
}
// å¤åˆ¶ BOM
export function copy(data) {
  return request({
    url: "/technologyBom/copy",
    method: "post",
    data: data,
  });
}
// èŽ·å–äº§å“åˆ—è¡¨ (用于新增BOM时选择产品)
export function getProductList(query) {
  return request({
    url: "/product/ledger/listPage",
    method: "get",
    params: query,
  });
}
// --- BOM ç»“构相关 ---
// æ ¹æ® BOM ID èŽ·å–ç»“æž„åˆ—è¡¨
export function queryStructureList(bomId) {
  return request({
    url: "/technologyBomStructure/listByBomId/" + bomId,
    method: "get",
  });
}
// ä¿å­˜ BOM ç»“æž„
export function addStructure(data) {
  return request({
    url: "/technologyBomStructure/batchSave",
    method: "post",
    data: data,
  });
}
// åˆ é™¤ BOM ç»“构项
export function deleteStructure(id) {
  return request({
    url: "/technologyBomStructure/batchDelete/" + id,
    method: "delete",
  });
}
src/api/productionManagement/processManagement.js
@@ -8,6 +8,13 @@
  });
}
export function list() {
  return request({
    url: "/technologyOperation/list",
    method: "get",
  });
}
export function add(data) {
  return request({
    url: "/technologyOperation/add",
src/pages.json
@@ -402,6 +402,27 @@
      }
    },
    {
      "path": "pages/productionDesign/bom/index",
      "style": {
        "navigationBarTitleText": "BOM管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/bom/structure",
      "style": {
        "navigationBarTitleText": "BOM结构",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/bom/edit",
      "style": {
        "navigationBarTitleText": "BOM详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/collaborativeApproval/index1",
      "style": {
        "navigationBarTitleText": "公出管理",
src/pages/productionDesign/bom/BomStructureItem.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,256 @@
<template>
  <view class="structure-item-wrapper"
        :class="{ 'is-root': level === 0, 'is-last': isLast }">
    <!-- æ ‘形连接线 (非根节点显示) -->
    <template v-if="level > 0">
      <view class="line-v"></view>
      <view class="line-h"></view>
    </template>
    <view class="structure-item-card"
          :class="{ 'has-children': hasChildren }">
      <view class="card-main">
        <view class="item-header"
              @click="toggleExpand">
          <view class="header-left">
            <view v-if="hasChildren"
                  class="expand-icon"
                  :class="{ 'is-expanded': isExpanded }">
              <up-icon name="arrow-right"
                       size="14"
                       color="#999"></up-icon>
            </view>
            <view v-else
                  class="dot-icon"></view>
            <text class="item-title">{{ item.productName || '未选择产品' }}</text>
          </view>
          <up-tag v-if="hasChildren"
                  text="组合"
                  type="primary"
                  size="mini"
                  plain
                  shape="circle" />
        </view>
        <view class="item-body">
          <view class="info-grid">
            <view class="info-item">
              <text class="label">规格型号:</text>
              <text class="value">{{ item.model || '-' }}</text>
            </view>
            <view class="info-item">
              <text class="label">消耗工序:</text>
              <text class="value">{{ getProcessName(item.processId) }}</text>
            </view>
            <view class="info-item">
              <text class="label">单位数量:</text>
              <text class="value highlight">{{ item.unitQuantity || 0 }}</text>
            </view>
            <view class="info-item">
              <text class="label">需求总量:</text>
              <text class="value highlight">{{ item.demandedQuantity || 0 }}</text>
            </view>
            <view class="info-item">
              <text class="label">单位:</text>
              <text class="value">{{ item.unit || '-' }}</text>
            </view>
            <view class="info-item">
              <text class="label">盘数:</text>
              <text class="value">{{ item.diskQuantity || 0 }}</text>
            </view>
          </view>
        </view>
      </view>
    </view>
    <!-- é€’归展示子节点 -->
    <view v-if="hasChildren && isExpanded"
          class="children-container">
      <BomStructureItem v-for="(child, index) in item.children"
                        :key="index"
                        :item="child"
                        :level="level + 1"
                        :isLast="index === item.children.length - 1"
                        :processOptions="processOptions" />
    </view>
  </view>
</template>
<script setup>
  import { ref, computed, defineProps } from "vue";
  const props = defineProps({
    item: {
      type: Object,
      required: true,
    },
    level: {
      type: Number,
      default: 0,
    },
    isLast: {
      type: Boolean,
      default: false,
    },
    processOptions: {
      type: Array,
      default: () => [],
    },
  });
  const isExpanded = ref(true);
  const hasChildren = computed(
    () => props.item.children && props.item.children.length > 0
  );
  const toggleExpand = () => {
    if (hasChildren.value) {
      isExpanded.value = !isExpanded.value;
    }
  };
  const getProcessName = id => {
    const process = props.processOptions.find(p => p.id === id);
    return process ? process.name : "-";
  };
</script>
<script>
  export default {
    name: "BomStructureItem",
  };
</script>
<style scoped lang="scss">
  .structure-item-wrapper {
    position: relative;
    padding-left: 44rpx;
    &.is-root {
      padding-left: 0;
    }
  }
  // åž‚直连接线段
  .line-v {
    position: absolute;
    left: 18rpx; // å±…中于 44rpx çš„缩进内
    top: -20rpx; // å‘上延伸覆盖上一个节点的 margin-bottom
    bottom: 0;
    width: 2rpx;
    background-color: #ddd;
    z-index: 1;
  }
  // æœ€åŽä¸€ä¸ªèŠ‚ç‚¹çš„åž‚ç›´çº¿åªå»¶ä¼¸åˆ°æ°´å¹³çº¿ä½ç½®
  .is-last > .line-v {
    bottom: auto;
    height: 60rpx; // 20rpx (top offset) + 40rpx (to horizontal line)
  }
  // æ°´å¹³è¿žæŽ¥çº¿
  .line-h {
    position: absolute;
    left: 18rpx;
    top: 40rpx; // å¯¹é½åˆ°å¡ç‰‡å†…部图标中心 (padding 24 + icon 32/2)
    width: 26rpx;
    height: 2rpx;
    background-color: #ddd;
    z-index: 1;
  }
  .structure-item-card {
    position: relative;
    background: #fff;
    border-radius: 16rpx;
    margin-bottom: 20rpx;
    padding: 24rpx;
    box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
    border: 1rpx solid #f0f0f0;
    transition: all 0.3s;
    z-index: 2;
    &:active {
      background-color: #f9f9f9;
    }
    &.has-children {
      border-left: 6rpx solid #3c9cff;
    }
  }
  .card-main {
    .item-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 20rpx;
      .header-left {
        display: flex;
        align-items: center;
        flex: 1;
        .expand-icon {
          margin-right: 12rpx;
          transition: transform 0.3s;
          display: flex;
          align-items: center;
          justify-content: center;
          width: 32rpx;
          height: 32rpx;
          &.is-expanded {
            transform: rotate(90deg);
          }
        }
        .dot-icon {
          width: 12rpx;
          height: 12rpx;
          border-radius: 50%;
          background-color: #ccc;
          margin-right: 20rpx;
          margin-left: 10rpx;
        }
        .item-title {
          font-size: 30rpx;
          font-weight: bold;
          color: #333;
          line-height: 1.4;
        }
      }
    }
    .item-body {
      .info-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 12rpx 20rpx;
        .info-item {
          display: flex;
          font-size: 24rpx;
          line-height: 1.5;
          .label {
            color: #999;
            white-space: nowrap;
          }
          .value {
            color: #666;
            word-break: break-all;
            &.highlight {
              color: #3c9cff;
              font-weight: 500;
            }
          }
        }
      }
    }
  }
  .children-container {
    position: relative;
  }
</style>
src/pages/productionDesign/bom/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,179 @@
<template>
  <view class="bom-list">
    <PageHeader title="BOM管理"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    v-model="queryParams.productName"
                    placeholder="请输入产品名称"
                    clearable
                    @change="handleSearch" />
        </view>
        <view class="filter-button"
              @click="handleSearch">
          <up-icon name="search"
                   size="24"
                   color="#999999"></up-icon>
        </view>
      </view>
    </view>
    <view v-if="list.length > 0"
          class="ledger-list">
      <view v-for="item in list"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="list-dot"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.bomNo || "-" }}</text>
          </view>
          <up-tag :text="'V' + (item.version || '1.0')"
                  type="primary"
                  size="mini" />
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">产品名称</text>
            <text class="detail-value">{{ item.productName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">规格型号</text>
            <text class="detail-value">{{ item.productModelName || "-" }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">备注</text>
            <text class="detail-value">{{ item.remark || "-" }}</text>
          </view>
        </view>
        <view class="action-buttons">
          <up-button class="action-btn"
                     size="small"
                     type="primary"
                     @click="goStructure(item)">查看详情</up-button>
        </view>
      </view>
      <up-loadmore :status="pageStatus" />
    </view>
    <view v-else
          class="no-data">
      <up-empty text="暂无BOM数据"
                mode="list"></up-empty>
    </view>
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onReachBottom, onShow } from "@dcloudio/uni-app";
  import { listPage } from "@/api/productionManagement/bom";
  const queryParams = reactive({
    productName: "",
  });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({
    current: 1,
    size: 3,
    total: 0,
  });
  const goBack = () => {
    uni.navigateBack();
  };
  const handleSearch = () => {
    page.current = 1;
    pageStatus.value = "loadmore";
    list.value = [];
    getList();
  };
  const getList = () => {
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    listPage({
      current: page.current,
      size: page.size,
      productName: queryParams.productName,
    })
      .then(res => {
        const records = res?.data?.records || res?.records || [];
        const total = res?.data?.total || res?.total || 0;
        if (page.current === 1) {
          list.value = records;
        } else {
          list.value = [...list.value, ...records];
        }
        page.total = total;
        if (list.value.length >= total) {
          pageStatus.value = "nomore";
        } else {
          pageStatus.value = "loadmore";
          page.current++;
        }
      })
      .catch(() => {
        uni.showToast({ title: "查询失败", icon: "error" });
        pageStatus.value = "loadmore";
      });
  };
  const goStructure = item => {
    uni.navigateTo({
      url: `/pages/productionDesign/bom/structure?id=${
        item.id
      }&bomNo=${encodeURIComponent(item.bomNo)}&productName=${encodeURIComponent(
        item.productName || ""
      )}&productModelName=${encodeURIComponent(
        item.productModelName || ""
      )}&remark=${encodeURIComponent(
        item.remark || ""
      )}&version=${encodeURIComponent(item.version || 1)}`,
    });
  };
  onReachBottom(() => {
    getList();
  });
  onShow(() => {
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/procurement-common.scss";
  .no-data {
    padding-top: 100rpx;
    text-align: center;
    color: #999;
    font-size: 28rpx;
  }
  .action-buttons {
    display: flex;
    justify-content: flex-end;
    gap: 15rpx;
    padding: 0 30rpx 30rpx;
    flex-wrap: wrap;
  }
  .action-btn {
    width: calc(50% - 15rpx);
    margin: 0 !important;
    margin-bottom: 15rpx !important;
  }
</style>
src/pages/productionDesign/bom/structure.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
<template>
  <view class="structure-page">
    <PageHeader :title="'BOM结构 - ' + bomNo"
                @back="goBack" />
    <view class="info-card">
      <view class="info-row">
        <text class="info-label">产品名称:</text>
        <text class="info-value">{{ productName }}-{{ productModelName }}</text>
      </view>
    </view>
    <view class="structure-list"
          v-if="dataList.length > 0">
      <BomStructureItem v-for="(item, index) in dataList"
                        :key="index"
                        :item="item"
                        :level="0"
                        :isLast="index === dataList.length - 1"
                        :processOptions="processOptions" />
    </view>
    <view v-else
          class="no-data">
      <up-empty text="暂无结构数据"
                mode="list"></up-empty>
    </view>
  </view>
</template>
<script setup>
  import { ref, reactive, computed } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import { queryStructureList } from "@/api/productionManagement/bom";
  import { list as getProcessList } from "@/api/productionManagement/processManagement";
  import BomStructureItem from "./BomStructureItem.vue";
  const bomId = ref(null);
  const bomNo = ref("");
  const productName = ref("");
  const dataList = ref([]);
  const processOptions = ref([]);
  const goBack = () => {
    uni.navigateBack();
  };
  const fetchData = () => {
    queryStructureList(bomId.value).then(res => {
      dataList.value = res.data || [];
    });
  };
  const fetchProcess = () => {
    getProcessList().then(res => {
      processOptions.value = res.data || [];
    });
  };
  const productModelName = ref("");
  onLoad(options => {
    bomId.value = options.id;
    bomNo.value = decodeURIComponent(options.bomNo);
    productName.value = decodeURIComponent(options.productName);
    productModelName.value = decodeURIComponent(options.productModelName);
    fetchData();
    fetchProcess();
  });
</script>
<style scoped lang="scss">
  .structure-page {
    background-color: #f5f5f5;
    min-height: 100vh;
    padding-bottom: 120rpx;
  }
  .info-card {
    background: #fff;
    padding: 30rpx;
    margin-bottom: 20rpx;
    .info-row {
      display: flex;
      font-size: 28rpx;
      .info-label {
        color: #666;
      }
      .info-value {
        color: #333;
        font-weight: bold;
      }
    }
  }
  .structure-list {
    padding: 20rpx;
  }
  .no-data {
    padding-top: 100rpx;
  }
</style>
src/pages/works.vue
@@ -1001,6 +1001,11 @@
          url: "/pages/productionDesign/processManagement/index",
        });
        break;
      case "BOM":
        uni.navigateTo({
          url: "/pages/productionDesign/bom/index",
        });
        break;
      default:
        uni.showToast({
          title: `点击了${item.label}`,