| | |
| | | <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> |
| | |
| | | <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"; |
| | | |
| | |
| | | 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; |
| | |
| | | .structure-page { |
| | | background-color: #f5f5f5; |
| | | min-height: 100vh; |
| | | padding-bottom: 120rpx; |
| | | padding-bottom: 200rpx; |
| | | } |
| | | |
| | | .info-card { |
| | |
| | | .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> |