src/views/mes/pro/workorder/modules/process-list.vue
@@ -11,8 +11,10 @@
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 {
@@ -21,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';
@@ -47,8 +50,16 @@
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,
@@ -65,51 +76,57 @@
    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,
    {
  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: ({ newDraggableIndex, oldDraggableIndex }) => {
    onEnd: (evt) => {
      const { oldIndex, newIndex, item } = evt;
        if (
          newDraggableIndex !== undefined &&
          oldDraggableIndex !== undefined &&
          oldDraggableIndex !== newDraggableIndex
        oldIndex === undefined ||
        newIndex === undefined ||
        oldIndex === newIndex
        ) {
          // 重新排序数组
          list.value.splice(
            newDraggableIndex,
            0,
            list.value.splice(oldDraggableIndex, 1)[0]!,
          );
        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((item, index) => {
            item.sort = index + 1;
      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;
  }
}
@@ -230,6 +247,81 @@
  }
}
/** 打开新增物料弹窗 */
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();
@@ -248,6 +340,32 @@
<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="[
@@ -282,6 +400,11 @@
        <TableAction
          :actions="[
            {
              label: '新增物料',
              type: 'link',
              onClick: handleMaterialInput.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              ifShow: isEditable,
@@ -300,7 +423,7 @@
          ]"
        />
      </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"
@@ -310,7 +433,9 @@
            <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>