<script lang="ts" setup>
|
import type { FormType } from '../data';
|
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
|
import type { MesProWorkOrderProcessApi } from '#/api/mes/pro/workorder/process';
|
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
|
import { useVbenModal } from '@vben/common-ui';
|
import { MesProWorkOrderStatusEnum } from '@vben/constants';
|
import { IconifyIcon } from '@vben/icons';
|
|
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 {
|
deleteWorkOrderProcess,
|
getWorkOrderProcessListByWorkOrderId,
|
} 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 props = defineProps<{
|
formType: FormType;
|
workOrder: MesProWorkOrderApi.WorkOrder;
|
workOrderId?: number;
|
productId?: number;
|
}>();
|
|
const emit = defineEmits<{
|
processListChange: [list: MesProWorkOrderProcessApi.WorkOrderProcess[]];
|
}>();
|
|
// 工序列表数据
|
const list = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]);
|
|
const isEditable = computed(() =>
|
['create', 'update'].includes(props.formType) &&
|
(props.workOrder?.status === MesProWorkOrderStatusEnum.PREPARE ||
|
props.formType === 'create'),
|
);
|
|
const isCreateMode = computed(() => props.formType === 'create');
|
|
// 拖拽排序实例
|
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,
|
destroyOnClose: true,
|
});
|
|
const [Grid, gridApi] = useVbenVxeGrid({
|
gridOptions: {
|
autoResize: true,
|
border: true,
|
columns: useProcessGridColumns(isEditable.value),
|
data: [],
|
minHeight: 240,
|
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();
|
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) {
|
sortableInstance.destroy();
|
sortableInstance = null;
|
}
|
}
|
|
/** 加载工序列表 */
|
async function getList() {
|
gridApi.setLoading(true);
|
try {
|
if (isCreateMode.value) {
|
// 新增模式:从工艺路线获取
|
if (!props.productId) {
|
list.value = [];
|
} else {
|
// 调用工艺路线接口获取工序列表
|
const routeProcessList = await getRouteProcessListByProduct(props.productId);
|
// 转换为工单工序格式
|
list.value = routeProcessList.map((item) => ({
|
processId: item.processId,
|
processCode: item.processCode,
|
processName: item.processName,
|
sort: item.sort,
|
nextProcessId: item.nextProcessId,
|
nextProcessName: item.nextProcessName,
|
linkType: item.linkType,
|
prepareTime: item.prepareTime,
|
waitTime: item.waitTime,
|
colorCode: item.colorCode,
|
keyFlag: item.keyFlag,
|
checkFlag: item.checkFlag,
|
backflushFlag: item.backflushFlag,
|
outputItemId: item.outputItemId,
|
outputItemCode: item.outputItemCode,
|
outputItemName: item.outputItemName,
|
remark: item.remark,
|
bomItems: item.bomItems,
|
}));
|
}
|
} else {
|
// 编辑模式:从工单工序获取
|
if (!props.workOrderId) {
|
list.value = [];
|
} else {
|
list.value = await getWorkOrderProcessListByWorkOrderId(props.workOrderId);
|
}
|
}
|
gridApi.setGridOptions({ data: list.value });
|
emit('processListChange', list.value);
|
// 初始化拖拽排序
|
initSortable();
|
} finally {
|
gridApi.setLoading(false);
|
}
|
}
|
|
/** 添加工序 */
|
function handleCreate() {
|
processFormModalApi
|
.setData({
|
workOrderId: props.workOrderId,
|
productId: props.productId || props.workOrder?.productId,
|
processList: list.value,
|
})
|
.open();
|
}
|
|
/** 编辑工序 */
|
function handleEdit(row: MesProWorkOrderProcessApi.WorkOrderProcess) {
|
processFormModalApi
|
.setData({
|
id: row.id,
|
workOrderId: props.workOrderId,
|
productId: props.productId || props.workOrder?.productId,
|
row,
|
processList: list.value,
|
})
|
.open();
|
}
|
|
/** 新增/编辑成功回调 */
|
function handleFormSuccess(data: MesProWorkOrderProcessApi.WorkOrderProcess, isEdit: boolean) {
|
if (isCreateMode.value) {
|
if (isEdit) {
|
const index = list.value.findIndex((item) => item.processId === data.processId);
|
if (index > -1) {
|
list.value[index] = data;
|
}
|
} else {
|
list.value.push(data);
|
}
|
gridApi.setGridOptions({ data: list.value });
|
emit('processListChange', list.value);
|
initSortable();
|
} else {
|
getList();
|
}
|
}
|
|
/** 删除工序 */
|
function handleDelete(row: MesProWorkOrderProcessApi.WorkOrderProcess) {
|
if (isCreateMode.value) {
|
// 新增模式:从列表删除
|
const index = list.value.findIndex((item) => item.processId === row.processId);
|
if (index > -1) {
|
list.value.splice(index, 1);
|
// 更新 sort 字段
|
list.value.forEach((item, idx) => {
|
item.sort = idx + 1;
|
});
|
gridApi.setGridOptions({ data: list.value });
|
emit('processListChange', list.value);
|
initSortable();
|
}
|
} else {
|
// 编辑模式:调用接口删除
|
deleteWorkOrderProcess(row.id!).then(() => {
|
message.success($t('ui.actionMessage.deleteSuccess', [row.processName]));
|
getList();
|
});
|
}
|
}
|
|
/** 打开新增物料弹窗 */
|
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();
|
});
|
|
// 监听产品/工单变化
|
watch(
|
() => [props.productId, props.workOrderId],
|
() => {
|
getList();
|
},
|
{ immediate: true },
|
);
|
</script>
|
|
<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="[
|
{
|
label: '添加工序',
|
type: 'primary',
|
onClick: handleCreate,
|
},
|
]"
|
/>
|
</div>
|
<Grid class="w-full" table-title="工序">
|
<template #dragHandle>
|
<IconifyIcon
|
icon="ic:round-drag-indicator"
|
class="drag-handle cursor-move text-xl text-gray-400"
|
/>
|
</template>
|
<template #keyFlag="{ row }">
|
<Tag v-if="row.keyFlag" color="red">关键</Tag>
|
<span v-else>-</span>
|
</template>
|
<template #checkFlag="{ row }">
|
<Tag v-if="row.checkFlag" color="blue">质检</Tag>
|
<span v-else>-</span>
|
</template>
|
<template #backflushFlag="{ row }">
|
<Tag v-if="row.backflushFlag" color="green">是</Tag>
|
<Tag v-else color="default">否</Tag>
|
</template>
|
<template #actions="{ row }">
|
<TableAction
|
:actions="[
|
{
|
label: '新增物料',
|
type: 'link',
|
onClick: handleMaterialInput.bind(null, row),
|
},
|
{
|
label: $t('common.edit'),
|
type: 'link',
|
ifShow: isEditable,
|
onClick: handleEdit.bind(null, row),
|
},
|
{
|
label: $t('common.delete'),
|
type: 'link',
|
danger: true,
|
ifShow: isEditable,
|
popConfirm: {
|
title: $t('ui.actionMessage.deleteConfirm', [row.processName]),
|
confirm: handleDelete.bind(null, row),
|
},
|
},
|
]"
|
/>
|
</template>
|
<template #expand_content="{ row }">
|
<div
|
v-if="row.bomItems && row.bomItems.length > 0"
|
class="bg-gray-50 px-8 py-3"
|
>
|
<div class="text-xs text-gray-500 mb-2 font-medium">投料明细</div>
|
<div class="flex flex-wrap gap-2">
|
<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>
|
</Tag>
|
</div>
|
</div>
|
</template>
|
</Grid>
|
</div>
|
</template>
|