zhangwencui
4 天以前 5f3dea3fd930afde720677fba10b6bfb2e836a18
src/views/mes/pro/workorder/modules/process-list.vue
@@ -5,12 +5,16 @@
import type { MesProWorkOrderApi } from '#/api/mes/pro/workorder';
import type { MesProWorkOrderProcessApi } from '#/api/mes/pro/workorder/process';
import { computed, ref, watch } from 'vue';
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 { 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 {
@@ -19,6 +23,7 @@
} 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';
@@ -45,6 +50,17 @@
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,
@@ -54,15 +70,65 @@
  gridOptions: {
    autoResize: true,
    border: true,
    columns: useProcessGridColumns(),
    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() {
@@ -94,6 +160,7 @@
          outputItemCode: item.outputItemCode,
          outputItemName: item.outputItemName,
          remark: item.remark,
          bomItems: item.bomItems,
        }));
      }
    } else {
@@ -106,6 +173,8 @@
    }
    gridApi.setGridOptions({ data: list.value });
    emit('processListChange', list.value);
    // 初始化拖拽排序
    initSortable();
  } finally {
    gridApi.setLoading(false);
  }
@@ -135,25 +204,6 @@
    .open();
}
/** 删除工序 */
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);
      gridApi.setGridOptions({ data: list.value });
      emit('processListChange', list.value);
    }
  } else {
    // 编辑模式:调用接口删除
    deleteWorkOrderProcess(row.id!).then(() => {
      message.success($t('ui.actionMessage.deleteSuccess', [row.processName]));
      getList();
    });
  }
}
/** 新增/编辑成功回调 */
function handleFormSuccess(data: MesProWorkOrderProcessApi.WorkOrderProcess, isEdit: boolean) {
  if (isCreateMode.value) {
@@ -167,10 +217,115 @@
    }
    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(
@@ -183,8 +338,34 @@
</script>
<template>
  <div>
  <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="[
@@ -197,6 +378,12 @@
      />
    </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>
@@ -212,6 +399,11 @@
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '新增物料',
              type: 'link',
              onClick: handleMaterialInput.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
@@ -231,6 +423,26 @@
          ]"
        />
      </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>
</template>