| | |
| | | import { MesProWorkOrderStatusEnum } from '@vben/constants'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { useSortable } from '@vueuse/integrations/useSortable'; |
| | | import { message, Tag } from 'ant-design-vue'; |
| | | import type { MesMdItemApi } from '#/api/mes/md/item'; |
| | | |
| | | import Sortable from 'sortablejs'; |
| | | import { Button, InputNumber, message, Modal, Tag } from 'ant-design-vue'; |
| | | |
| | | import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { |
| | |
| | | } from '#/api/mes/pro/workorder/process'; |
| | | import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process'; |
| | | import { $t } from '#/locales'; |
| | | import { MdItemSelectDialog } from '#/views/mes/md/item/components'; |
| | | |
| | | import { useProcessGridColumns } from '../data'; |
| | | import ProcessForm from './process-form.vue'; |
| | |
| | | |
| | | const isCreateMode = computed(() => props.formType === 'create'); |
| | | |
| | | // 拖拽排序 |
| | | const sortableInstance = ref<any>(null); |
| | | // 拖拽排序实例 |
| | | let sortableInstance: Sortable | null = null; |
| | | |
| | | // 物料投入 |
| | | const itemSelectDialogRef = ref<InstanceType<typeof MdItemSelectDialog>>(); |
| | | const currentMaterialRow = ref<MesProWorkOrderProcessApi.WorkOrderProcess>(); |
| | | |
| | | // 用料比例弹窗 |
| | | const quantityModalOpen = ref(false); |
| | | const pendingItems = ref<(MesMdItemApi.Item & { quantity: number })[]>([]); |
| | | |
| | | const [ProcessFormModal, processFormModalApi] = useVbenModal({ |
| | | connectedComponent: ProcessForm, |
| | |
| | | pagerConfig: { enabled: false }, |
| | | rowConfig: { isHover: true, keyField: 'processId' }, |
| | | showOverflow: true, |
| | | expandConfig: { |
| | | padding: true, |
| | | }, |
| | | toolbarConfig: { enabled: false }, |
| | | } as VxeTableGridOptions<MesProWorkOrderProcessApi.WorkOrderProcess>, |
| | | }); |
| | | |
| | | /** 初始化/销毁拖拽排序 */ |
| | | /** 初始化拖拽排序 */ |
| | | async function initSortable() { |
| | | destroySortable(); |
| | | if (!isEditable.value || list.value.length === 0) return; |
| | | await nextTick(); |
| | | sortableInstance.value = useSortable( |
| | | '.workorder-process-list .vxe-table--body-wrapper:not(.fixed-right--wrapper) .vxe-table--body tbody', |
| | | list.value, |
| | | { |
| | | animation: 150, |
| | | draggable: '.vxe-body--row', |
| | | handle: '.drag-handle', |
| | | onEnd: ({ newDraggableIndex, oldDraggableIndex }) => { |
| | | if ( |
| | | newDraggableIndex !== undefined && |
| | | oldDraggableIndex !== undefined && |
| | | oldDraggableIndex !== newDraggableIndex |
| | | ) { |
| | | // 重新排序数组 |
| | | list.value.splice( |
| | | newDraggableIndex, |
| | | 0, |
| | | list.value.splice(oldDraggableIndex, 1)[0]!, |
| | | ); |
| | | // 更新 sort 字段 |
| | | list.value.forEach((item, index) => { |
| | | item.sort = index + 1; |
| | | }); |
| | | gridApi.setGridOptions({ data: list.value }); |
| | | emit('processListChange', list.value); |
| | | } |
| | | }, |
| | | }, |
| | | const el = document.querySelector<HTMLElement>( |
| | | '.workorder-process-list .vxe-table--body-wrapper.body--wrapper .vxe-table--body tbody', |
| | | ); |
| | | if (!el) return; |
| | | sortableInstance = Sortable.create(el, { |
| | | animation: 150, |
| | | draggable: '.vxe-body--row', |
| | | handle: '.drag-handle', |
| | | onEnd: (evt) => { |
| | | const { oldIndex, newIndex, item } = evt; |
| | | if ( |
| | | oldIndex === undefined || |
| | | newIndex === undefined || |
| | | oldIndex === newIndex |
| | | ) { |
| | | return; |
| | | } |
| | | // 还原 DOM:Sortable 已移动节点,需放回原位,由数据驱动重渲染 |
| | | if (item && oldIndex < el.children.length) { |
| | | const refNode = el.children[oldIndex] || null; |
| | | el.insertBefore(item, refNode); |
| | | } |
| | | // 更新数据顺序 |
| | | list.value.splice(newIndex, 0, list.value.splice(oldIndex, 1)[0]!); |
| | | // 更新 sort 字段 |
| | | list.value.forEach((p, index) => { |
| | | p.sort = index + 1; |
| | | }); |
| | | gridApi.setGridOptions({ data: list.value }); |
| | | emit('processListChange', list.value); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | /** 销毁拖拽排序实例 */ |
| | | function destroySortable() { |
| | | if (sortableInstance.value) { |
| | | sortableInstance.value.stop?.(); |
| | | sortableInstance.value = null; |
| | | if (sortableInstance) { |
| | | sortableInstance.destroy(); |
| | | sortableInstance = null; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /** 打开新增物料弹窗 */ |
| | | function handleMaterialInput(row: MesProWorkOrderProcessApi.WorkOrderProcess) { |
| | | currentMaterialRow.value = row; |
| | | const selectedIds = (row.bomItems || []) |
| | | .map((item) => item.itemId) |
| | | .filter((id): id is number => id !== undefined); |
| | | itemSelectDialogRef.value?.open(selectedIds, { multiple: true }); |
| | | } |
| | | |
| | | /** 物料选中回调:去重后打开用料比例填写弹窗 */ |
| | | function handleItemSelected(rows: MesMdItemApi.Item[]) { |
| | | const row = currentMaterialRow.value; |
| | | if (!row || rows.length === 0) return; |
| | | const existingIds = new Set( |
| | | (row.bomItems || []) |
| | | .map((item) => item.itemId) |
| | | .filter((id): id is number => id !== undefined), |
| | | ); |
| | | const newItems = rows.filter((item) => item.id && !existingIds.has(item.id)); |
| | | if (newItems.length === 0) { |
| | | message.warning('所选物料均已存在'); |
| | | return; |
| | | } |
| | | pendingItems.value = newItems.map((item) => ({ ...item, quantity: 1 })); |
| | | quantityModalOpen.value = true; |
| | | } |
| | | |
| | | /** 确认用料比例,添加到 bomItems */ |
| | | function handleQuantityConfirm() { |
| | | const row = currentMaterialRow.value; |
| | | if (!row) return; |
| | | // 在 list 中找到对应行并替换,确保响应式更新 |
| | | const rowIndex = list.value.findIndex( |
| | | (item) => item.processId === row.processId, |
| | | ); |
| | | if (rowIndex === -1) return; |
| | | const target = list.value[rowIndex]!; |
| | | const newBomItems = [ |
| | | ...(target.bomItems || []), |
| | | ...pendingItems.value.map((item) => ({ |
| | | itemId: item.id, |
| | | itemCode: item.code, |
| | | itemName: item.name, |
| | | quantity: item.quantity, |
| | | remark: undefined, |
| | | })), |
| | | ]; |
| | | // 创建新的 row 对象和新的 list 数组,强制响应式更新 |
| | | list.value[rowIndex] = { ...target, bomItems: newBomItems }; |
| | | quantityModalOpen.value = false; |
| | | pendingItems.value = []; |
| | | gridApi.setGridOptions({ data: list.value }); |
| | | emit('processListChange', list.value); |
| | | message.success('物料添加成功'); |
| | | } |
| | | |
| | | /** 删除投料明细 */ |
| | | function handleRemoveBomItem( |
| | | row: MesProWorkOrderProcessApi.WorkOrderProcess, |
| | | index: number, |
| | | ) { |
| | | const rowIndex = list.value.findIndex( |
| | | (item) => item.processId === row.processId, |
| | | ); |
| | | if (rowIndex === -1) return; |
| | | const target = list.value[rowIndex]!; |
| | | if (!target.bomItems) return; |
| | | list.value[rowIndex] = { |
| | | ...target, |
| | | bomItems: target.bomItems.filter((_, i) => i !== index), |
| | | }; |
| | | gridApi.setGridOptions({ data: list.value }); |
| | | emit('processListChange', list.value); |
| | | } |
| | | |
| | | // 组件卸载时销毁排序实例 |
| | | onUnmounted(() => { |
| | | destroySortable(); |
| | |
| | | <template> |
| | | <div class="workorder-process-list"> |
| | | <ProcessFormModal @success="handleFormSuccess" /> |
| | | <MdItemSelectDialog ref="itemSelectDialogRef" @selected="handleItemSelected" /> |
| | | <Modal |
| | | v-model:open="quantityModalOpen" |
| | | title="填写用料比例" |
| | | width="500px" |
| | | @ok="handleQuantityConfirm" |
| | | > |
| | | <div class="space-y-3 py-2"> |
| | | <div |
| | | v-for="(item, idx) in pendingItems" |
| | | :key="item.id" |
| | | class="flex items-center gap-3" |
| | | > |
| | | <span class="shrink-0 text-sm text-gray-700"> |
| | | {{ item.name || item.code }} |
| | | </span> |
| | | <InputNumber |
| | | v-model:value="item.quantity" |
| | | :min="0.01" |
| | | :precision="2" |
| | | class="!w-32" |
| | | /> |
| | | <span class="text-xs text-gray-400">用量比例</span> |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | | <div v-if="isEditable" class="mb-3 flex items-center justify-start"> |
| | | <TableAction |
| | | :actions="[ |
| | |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: '新增物料', |
| | | type: 'link', |
| | | onClick: handleMaterialInput.bind(null, row), |
| | | }, |
| | | { |
| | | label: $t('common.edit'), |
| | | type: 'link', |
| | | ifShow: isEditable, |
| | |
| | | ]" |
| | | /> |
| | | </template> |
| | | <template #expand="{ row }"> |
| | | <template #expand_content="{ row }"> |
| | | <div |
| | | v-if="row.bomItems && row.bomItems.length > 0" |
| | | class="bg-gray-50 px-8 py-3" |
| | |
| | | <Tag |
| | | v-for="(bom, idx) in row.bomItems" |
| | | :key="idx" |
| | | closable |
| | | color="blue" |
| | | @close="handleRemoveBomItem(row, idx)" |
| | | > |
| | | {{ bom.itemName || bom.itemCode }} |
| | | <span class="text-gray-400 ml-1">×{{ bom.quantity }}</span> |
| | |
| | | </template> |
| | | </Grid> |
| | | </div> |
| | | </template> |
| | | </template> |