2026-09-03 6738e8f5a78431e41fed28e6b795e70c2396070a
refactor(mes): 优化工艺路线配置与工单表单功能

- 移除安灯呼叫相关的统计计数和快速操作入口
- 在工单表单中为多个字段添加帮助提示信息
- 优化工艺路线产品绑定表单,支持多选批量绑定产品
- 更新工艺路线配置界面,采用工作台模式替代原有的标签页
- 为产品BOM选择组件添加多选功能支持
- 在工单工序配置中添加物料需求说明信息
- 优化AI智能分析区域的布局显示效果
已添加1个文件
已修改13个文件
1235 ■■■■ 文件已修改
src/views/dashboard/analytics/index.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/product-bom-select-dialog.vue 180 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/product-bom-select.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 61 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/modules/form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/form.vue 142 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/product-form.vue 103 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/workbench.vue 545 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue
@@ -95,7 +95,6 @@
      </Col>
      <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4">
        <AlertPanel
          :andon-active-count="summary.andonActiveCount"
          :repair-active-count="summary.repairActiveCount"
          @navigate="handleNavigate"
        />
src/views/dashboard/analytics/modules/alert-panel.vue
@@ -15,7 +15,6 @@
defineOptions({ name: 'DashboardAlertPanel' });
const props = defineProps<{
  andonActiveCount: number;
  repairActiveCount: number;
}>();
@@ -67,24 +66,7 @@
<template>
  <div class="space-y-4">
    <!-- é¢„警卡片行 -->
    <div class="grid grid-cols-2 gap-3">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProAndon')"
      >
        <div class="relative z-10 flex items-center gap-3">
          <div class="flex size-10 items-center justify-center rounded-lg bg-red-50 text-red-600 shadow-sm dark:bg-red-500/15 dark:text-red-400">
            <IconifyIcon class="size-5" icon="lucide:alert-triangle" />
          </div>
          <div>
            <div class="text-xs text-gray-500 dark:text-gray-400">安灯呼叫</div>
            <div class="flex items-baseline gap-1">
              <CountTo class="text-xl font-bold text-red-600 dark:text-red-400" :end-val="andonActiveCount" :duration="1000" />
              <span class="text-xs text-gray-400">未处置</span>
            </div>
          </div>
        </div>
      </div>
    <div class="grid grid-cols-1 gap-3">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0 p-4"
        @click="emit('navigate', 'MesProRepair')"
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -21,7 +21,6 @@
  { title: '质检管理', icon: 'lucide:clipboard-check', route: 'MesQcRecord', gradient: 'from-orange-500 to-amber-500', shadow: 'shadow-orange-500/15' },
  { title: '设备点检', icon: 'lucide:settings', route: 'MesDvMachinery', gradient: 'from-violet-500 to-purple-500', shadow: 'shadow-violet-500/15' },
  { title: '生产排程', icon: 'lucide:calendar-range', route: 'MesProScheduling', gradient: 'from-cyan-500 to-teal-500', shadow: 'shadow-cyan-500/15' },
  { title: '安灯呼叫', icon: 'lucide:bell-ring', route: 'MesProAndon', gradient: 'from-red-500 to-rose-500', shadow: 'shadow-red-500/15' },
  { title: '物料库存', icon: 'lucide:package-search', route: 'WlsMaterialStock', gradient: 'from-indigo-500 to-blue-500', shadow: 'shadow-indigo-500/15' },
  { title: '工艺路线', icon: 'lucide:route', route: 'MesPdRouting', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
  { title: '数据报表', icon: 'lucide:bar-chart-big', route: 'MesHomeIndex', gradient: 'from-pink-500 to-rose-500', shadow: 'shadow-pink-500/15' },
@@ -31,7 +30,7 @@
<template>
  <div class="glass-card rounded-2xl p-5">
    <h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">快捷操作</h3>
    <div class="grid grid-cols-3 gap-3 sm:grid-cols-9">
    <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
      <div
        v-for="action in actions"
        :key="action.route"
src/views/mes/md/item/components/product-bom-select-dialog.vue
@@ -13,26 +13,115 @@
defineOptions({ name: 'MdProductBomSelectDialog' });
const props = withDefaults(
  defineProps<{
    multiple?: boolean;
  }>(),
  {
    multiple: false,
  },
);
const emit = defineEmits<{
  selected: [row: MesMdProductBomApi.ProductBom];
  selected: [rows: MesMdProductBomApi.ProductBom[]];
}>();
const open = ref(false); // å¼¹çª—是否打开
const list = ref<MesMdProductBomApi.ProductBom[]>([]); // BOM å­ç‰©æ–™åˆ—表
const selectedRow = ref<MesMdProductBomApi.ProductBom>(); // å½“前选中的 BOM è¡Œ
const selectedRows = ref<MesMdProductBomApi.ProductBom[]>([]); // å·²é€‰ BOM è¡Œï¼ˆå•选时最多一行)
const preSelectedIds = ref<number[]>([]); // é¢„选 BOM ç‰©æ–™ç¼–号
/** èŽ·å–å¤šé€‰è®°å½•ï¼ŒåŒ…å« VXE reserve è®°å½• */
function getMultipleSelectedRows() {
  const selectedMap = new Map<number, MesMdProductBomApi.ProductBom>();
  const records = [
    ...(gridApi.grid.getCheckboxReserveRecords?.() ?? []),
    ...(gridApi.grid.getCheckboxRecords?.() ?? []),
  ] as MesMdProductBomApi.ProductBom[];
  records.forEach((row) => {
    if (row.bomItemId != null) {
      selectedMap.set(row.bomItemId, row);
    }
  });
  return [...selectedMap.values()];
}
/** å¤„理勾选变化 */
function handleCheckboxSelectChange() {
  selectedRows.value = getMultipleSelectedRows();
}
/** ä¸Šå‘¨å•击行痕迹,用于吞掉双击触发的第二次 click */
let lastClick = { bomItemId: undefined as number | undefined, time: 0 };
/** å•击行:多选切换勾选;单选由 radioConfig.trigger='row' å¤„理 */
async function handleCellClick({
  row,
  column,
}: {
  row: MesMdProductBomApi.ProductBom;
  column?: { type?: string };
}) {
  // ç‚¹å‡»å‹¾é€‰/单选列自身由控件处理,避免二次切换
  if (column?.type === 'checkbox' || column?.type === 'radio') {
    return;
  }
  if (!props.multiple) {
    return;
  }
  const now = Date.now();
  if (lastClick.bomItemId === row.bomItemId && now - lastClick.time < 300) {
    lastClick.time = 0;
    return;
  }
  lastClick = { bomItemId: row.bomItemId, time: now };
  const selected = gridApi.grid.isCheckedByCheckboxRow(row);
  await gridApi.grid.setCheckboxRow(row, !selected);
  selectedRows.value = getMultipleSelectedRows();
}
/** å¤„理行双击 */
async function handleCellDblclick({
  row,
}: {
  row: MesMdProductBomApi.ProductBom;
}) {
  if (props.multiple) {
    return; // å¤šé€‰ç”±å•击 cellClick åˆ‡æ¢ï¼ŒåŒå‡»ä¸å†é‡å¤å¤„理
  }
  selectedRows.value = [row];
  gridApi.grid.setRadioRow(row);
  handleConfirm();
}
/** å¤„理单选变化 */
function handleRadioChange(row: MesMdProductBomApi.ProductBom) {
  selectedRows.value = [row];
}
/** å½“前选择列类型 */
function getSelectColumn() {
  return {
    type: props.multiple ? 'checkbox' : 'radio',
    width: 55,
    align: 'center',
  } as const;
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    border: true,
    columns: [
      { type: 'radio', width: 55, align: 'center' },
      ...(useProductBomGridColumns(true) || []),
    ],
    columns: [getSelectColumn(), ...(useProductBomGridColumns(true) || [])],
    data: list.value,
    height: 500,
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    checkboxConfig: {
      highlight: true,
      range: true,
      reserve: true,
    },
    radioConfig: {
      highlight: true,
@@ -48,35 +137,62 @@
    },
  } as VxeTableGridOptions<MesMdProductBomApi.ProductBom>,
  gridEvents: {
    cellDblclick: ({ row }: { row: MesMdProductBomApi.ProductBom }) => {
      selectedRow.value = row;
      gridApi.grid.setRadioRow(row);
      handleConfirm();
    },
    cellClick: handleCellClick,
    cellDblclick: handleCellDblclick,
    checkboxChange: handleCheckboxSelectChange,
    checkboxAll: handleCheckboxSelectChange,
    radioChange: ({ row }: { row: MesMdProductBomApi.ProductBom }) => {
      selectedRow.value = row;
      handleRadioChange(row);
    },
  },
});
/** æ‰“å¼€ BOM ç‰©æ–™é€‰æ‹©å¼¹çª— */
async function openModal(itemId: number, selectedBomItemId?: number) {
/** å›žæ˜¾é¢„选 BOM ç‰©æ–™ï¼ˆæ•°æ®ä¸€æ¬¡æ€§åŠ è½½ï¼Œæ— åˆ†é¡µï¼Œç›´æŽ¥å‹¾é€‰å³å¯ï¼‰ */
async function applyPreSelection() {
  if (preSelectedIds.value.length === 0) {
    return;
  }
  for (const row of list.value) {
    if (
      row.bomItemId === undefined ||
      row.bomItemId === null ||
      !preSelectedIds.value.includes(row.bomItemId)
    ) {
      continue;
    }
    if (props.multiple) {
      await gridApi.grid.setCheckboxRow(row, true);
    } else {
      await gridApi.grid.setRadioRow(row);
      selectedRows.value = [row];
      return;
    }
  }
  if (props.multiple) {
    selectedRows.value = getMultipleSelectedRows();
  }
}
/** æ‰“å¼€ BOM ç‰©æ–™é€‰æ‹©å¼¹çª—(selectedBomItemIds å•选传单个编号,多选传编号数组) */
async function openModal(itemId: number, selectedBomItemIds?: number | number[]) {
  open.value = true;
  selectedRow.value = undefined;
  preSelectedIds.value = (
    Array.isArray(selectedBomItemIds)
      ? selectedBomItemIds
      : selectedBomItemIds === undefined || selectedBomItemIds === null
        ? []
        : [selectedBomItemIds]
  ).filter((id): id is number => id != null);
  selectedRows.value = [];
  gridApi.setLoading(true);
  try {
    list.value = await getProductBomListByItemId(itemId);
    gridApi.setGridOptions({ data: list.value });
    gridApi.setGridOptions({
      data: list.value,
      columns: [getSelectColumn(), ...(useProductBomGridColumns(true) || [])],
    });
    await nextTick();
    if (selectedBomItemId !== null) {
      const match = list.value.find(
        (row) => row.bomItemId === selectedBomItemId,
      );
      if (match) {
        selectedRow.value = match;
        gridApi.grid.setRadioRow(match);
      }
    }
    await applyPreSelection();
  } finally {
    gridApi.setLoading(false);
  }
@@ -85,17 +201,23 @@
/** å…³é—­ BOM ç‰©æ–™é€‰æ‹©å¼¹çª— */
async function closeModal() {
  open.value = false;
  selectedRow.value = undefined;
  selectedRows.value = [];
  preSelectedIds.value = [];
  await gridApi.grid.clearCheckboxRow();
  await gridApi.grid.clearCheckboxReserve();
  await gridApi.grid.clearRadioRow();
}
/** ç¡®è®¤é€‰æ‹© BOM ç‰©æ–™ */
function handleConfirm() {
  if (!selectedRow.value) {
    message.warning('请选择一条数据');
  const rows = props.multiple
    ? getMultipleSelectedRows()
    : selectedRows.value.slice(0, 1);
  if (rows.length === 0) {
    message.warning(props.multiple ? '请至少选择一条数据' : '请选择一条数据');
    return;
  }
  emit('selected', selectedRow.value);
  emit('selected', rows);
  open.value = false;
}
src/views/mes/md/item/components/product-bom-select.vue
@@ -18,7 +18,8 @@
    allowClear?: boolean;
    disabled?: boolean;
    itemId?: number;
    modelValue?: number;
    modelValue?: number | number[];
    multiple?: boolean;
    placeholder?: string;
  }>(),
  {
@@ -26,50 +27,68 @@
    disabled: false,
    itemId: undefined,
    modelValue: undefined,
    multiple: false,
    placeholder: '请选择 BOM ç‰©æ–™',
  },
);
const emit = defineEmits<{
  change: [bom: MesMdProductBomApi.ProductBom | undefined];
  'update:modelValue': [value: number | undefined];
  change: [
    bom: MesMdProductBomApi.ProductBom[] | MesMdProductBomApi.ProductBom | undefined,
  ];
  'update:modelValue': [value: number[] | number | undefined];
}>();
const attrs = useAttrs(); // é€ä¼ å±žæ€§
const dialogRef = ref<InstanceType<typeof MdProductBomSelectDialog>>(); // BOM ç‰©æ–™é€‰æ‹©å¼¹çª—
const hovering = ref(false); // æ˜¯å¦æ‚¬åœ
const selectedBom = ref<MesMdProductBomApi.ProductBom>(); // å½“前选中的 BOM ç‰©æ–™
const selectedBoms = ref<MesMdProductBomApi.ProductBom[]>([]); // å·²é€‰ BOM ç‰©æ–™ï¼ˆå•选时最多一项)
const displayLabel = computed(() => selectedBom.value?.bomItemName ?? ''); // é€‰æ‹©å™¨å±•示名称
const showClear = computed(
  () =>
    props.allowClear &&
    !props.disabled &&
    hovering.value &&
    props.modelValue !== undefined &&
    props.modelValue !== null,
);
/** æ ¹æ® BOM å­ç‰©æ–™ç¼–号回显选择器 */
async function resolveBomById(bomItemId: number | undefined) {
  if (
    bomItemId === undefined ||
    bomItemId === null ||
    props.itemId === undefined ||
    props.itemId === null
  ) {
    selectedBom.value = undefined;
    return;
const selectedBom = computed(() => selectedBoms.value[0]); // å•选时的当前项
const displayLabel = computed(() => {
  // å¤šé€‰å±•示「名称、名称」,单选展示单个名称
  if (props.multiple) {
    return selectedBoms.value
      .map((bom) => bom.bomItemName)
      .filter(Boolean)
      .join('、');
  }
  if (selectedBom.value?.bomItemId === bomItemId) {
  return selectedBom.value?.bomItemName ?? '';
}); // é€‰æ‹©å™¨å±•示名称
const showClear = computed(() => {
  if (!props.allowClear || props.disabled || !hovering.value) {
    return false;
  }
  if (props.multiple) {
    return Array.isArray(props.modelValue) && props.modelValue.length > 0;
  }
  return props.modelValue !== undefined && props.modelValue !== null;
});
/** ä»Ž modelValue è§£æžå‡º BOM ç‰©æ–™ç¼–号列表 */
function getModelIds() {
  if (props.multiple) {
    return Array.isArray(props.modelValue)
      ? props.modelValue.filter((id): id is number => id != null)
      : [];
  }
  return typeof props.modelValue === 'number' ? [props.modelValue] : [];
}
/** æ ¹æ® BOM ç‰©æ–™ç¼–号回显选择器 */
async function resolveBomsById(ids: number[]) {
  if (ids.length === 0 || props.itemId === undefined || props.itemId === null) {
    selectedBoms.value = [];
    return;
  }
  const list = await getProductBomListByItemId(props.itemId as number);
  selectedBom.value = list.find((item) => item.bomItemId === bomItemId);
  selectedBoms.value = list.filter(
    (item) => item.bomItemId != null && ids.includes(item.bomItemId),
  );
}
watch(
  () => props.modelValue,
  (value) => {
    resolveBomById(value);
  () => {
    resolveBomsById(getModelIds());
  },
  { immediate: true },
);
@@ -77,17 +96,17 @@
watch(
  () => props.itemId,
  () => {
    selectedBom.value = undefined;
    emit('update:modelValue', undefined);
    emit('change', undefined);
    selectedBoms.value = [];
    emit('update:modelValue', props.multiple ? [] : undefined);
    emit('change', props.multiple ? [] : undefined);
  },
);
/** æ¸…空已选 BOM ç‰©æ–™ */
function clearSelected() {
  selectedBom.value = undefined;
  emit('update:modelValue', undefined);
  emit('change', undefined);
  selectedBoms.value = [];
  emit('update:modelValue', props.multiple ? [] : undefined);
  emit('change', props.multiple ? [] : undefined);
}
/** æ‰“å¼€ BOM ç‰©æ–™é€‰æ‹©å¼¹çª— */
@@ -101,14 +120,22 @@
    clearSelected();
    return;
  }
  dialogRef.value?.open(props.itemId as number, props.modelValue);
  const ids = getModelIds();
  dialogRef.value?.open(props.itemId as number, props.multiple ? ids : ids[0]);
}
/** å›žå¡«é€‰ä¸­çš„ BOM ç‰©æ–™ */
function handleSelected(row: MesMdProductBomApi.ProductBom) {
  selectedBom.value = row;
  emit('update:modelValue', row.bomItemId);
  emit('change', row);
function handleSelected(rows: MesMdProductBomApi.ProductBom[]) {
  selectedBoms.value = rows;
  emit(
    'update:modelValue',
    props.multiple
      ? rows
          .map((row) => row.bomItemId)
          .filter((id): id is number => id != null)
      : rows[0]?.bomItemId,
  );
  emit('change', props.multiple ? rows : rows[0]);
}
</script>
@@ -121,9 +148,14 @@
    @mouseenter="hovering = true"
    @mouseleave="hovering = false"
  >
    <Tooltip :mouse-enter-delay="0.5" :open="selectedBom ? undefined : false">
    <Tooltip :mouse-enter-delay="0.5" :open="selectedBoms.length ? undefined : false">
      <template #title>
        <div v-if="selectedBom" class="leading-6">
        <div v-if="multiple" class="leading-6">
          <div v-for="bom in selectedBoms" :key="bom.bomItemId">
            {{ bom.bomItemName || '-' }}{{ bom.bomItemCode ? `(${bom.bomItemCode})` : '' }}
          </div>
        </div>
        <div v-else-if="selectedBom" class="leading-6">
          <div>编码:{{ selectedBom.bomItemCode || '-' }}</div>
          <div>名称:{{ selectedBom.bomItemName || '-' }}</div>
          <div>规格:{{ selectedBom.bomItemSpecification || '-' }}</div>
@@ -146,5 +178,9 @@
      </Input>
    </Tooltip>
  </div>
  <MdProductBomSelectDialog ref="dialogRef" @selected="handleSelected" />
  <MdProductBomSelectDialog
    ref="dialogRef"
    :multiple="multiple"
    @selected="handleSelected"
  />
</template>
src/views/mes/md/item/components/select-dialog.vue
@@ -67,11 +67,38 @@
    selectedRows.value = getMultipleSelectedRows();
  }
  /** ä¸Šæ¬¡å•击行痕迹,用于吞掉双击触发的第二次 click,避免"选了又取消" */
  let lastClick = { rowId: undefined as number | undefined, time: 0 };
  /** å•击行:多选直接切换勾选;单选由 radioConfig.trigger='row' å¤„理 */
  async function handleCellClick({
    row,
    column,
  }: {
    row: MesMdItemApi.Item;
    column?: { type?: string };
  }) {
    // ç‚¹å‡»å‹¾é€‰åˆ—自身由 checkbox æŽ§ä»¶å¤„理,避免二次切换
    if (column?.type === "checkbox") {
      return;
    }
    if (!multiple.value) {
      return;
    }
    const rowId = row.id;
    const now = Date.now();
    if (lastClick.rowId === rowId && now - lastClick.time < 300) {
      lastClick.time = 0;
      return;
    }
    lastClick = { rowId, time: now };
    await toggleMultipleRow(row);
  }
  /** å¤„理行双击 */
  async function handleCellDblclick({ row }: { row: MesMdItemApi.Item }) {
    if (multiple.value) {
      await toggleMultipleRow(row);
      return;
      return; // å¤šé€‰ç”±å•击 cellClick åˆ‡æ¢ï¼ŒåŒå‡»ä¸å†é‡å¤å¤„理
    }
    selectedRows.value = [row];
    await gridApi.grid.setRadioRow(row);
@@ -84,18 +111,20 @@
    gridApi.query();
  }
  /** å›žæ˜¾é¢„选物料 */
  /** å›žæ˜¾é¢„选物料:当前页直接勾选,跨页预选注册为 reserve,翻页后自动回显 */
  async function applyPreSelection() {
    if (preSelectedIds.value.length === 0) {
      return;
    }
    const rows = getTableRows();
    const currentIds = new Set(
      rows.filter((row) => row.id != null).map((row) => row.id),
    );
    for (const row of rows) {
      if (
        row.id === undefined ||
        row.id === null ||
        !preSelectedIds.value.includes(row.id)
      ) {
      if (row.id === undefined || row.id === null) {
        continue;
      }
      if (!preSelectedIds.value.includes(row.id)) {
        continue;
      }
      if (multiple.value) {
@@ -107,6 +136,21 @@
      }
    }
    if (multiple.value) {
      // æ‹‰å–不在当前页的预选物料完整数据,注册为 checkbox reserve(翻页后自动勾选)
      const missingIds = preSelectedIds.value.filter(
        (id) => !currentIds.has(id),
      );
      if (missingIds.length > 0) {
        const result = await getItemPage({
          pageNo: 1,
          pageSize: 200,
          ids: missingIds.join(","),
        } as never);
        const fullRows = result?.list as MesMdItemApi.Item[] | undefined;
        if (fullRows && fullRows.length > 0) {
          await gridApi.grid.setCheckboxReserve(fullRows, true);
        }
      }
      selectedRows.value = getMultipleSelectedRows();
    }
  }
@@ -159,6 +203,7 @@
      },
    } as VxeTableGridOptions<MesMdItemApi.Item>,
    gridEvents: {
      cellClick: handleCellClick,
      cellDblclick: handleCellDblclick,
      checkboxAll: handleCheckboxSelectChange,
      checkboxChange: handleCheckboxSelectChange,
src/views/mes/md/item/modules/form.vue
@@ -129,9 +129,15 @@
      class="mx-4 mt-4"
    >
      <Tabs.TabPane key="bom" tab="BOM ç»„成">
        <p class="mb-3 text-xs text-gray-400">
          æ­¤å¤„维护的是产品自身的物料构成(配方),并非工艺路线的投料 BOM;在「工艺流程」工作台配置「投入物料」时,会从该 BOM ä¸­é€‰æ‹©ç‰©æ–™ã€‚
        </p>
        <ProductBomForm :form-type="formType" :item-id="formData.id" />
      </Tabs.TabPane>
      <Tabs.TabPane v-if="formData.batchFlag" key="batch" tab="批次属性">
        <p class="mb-3 text-xs text-gray-400">
          æ­¤å¤„配置每个批次需要登记的属性维度,勾选后入库、报工、质检等环节将按所选维度生成批次信息。物料与产品的可选属性不同:物料侧重供应商、有效期等,产品侧重订单、工序、工作站等。
        </p>
        <ItemBatchConfigForm
          :form-type="formType"
          :item-id="formData.id"
src/views/mes/pro/workorder/data.ts
@@ -127,6 +127,10 @@
          }
        },
      },
      help: (values: { orderSourceType?: number }) =>
        values.orderSourceType === MesProWorkOrderSourceTypeEnum.ORDER
          ? '已选客户订单:需补充来源单据编号与客户,便于追溯订单来源。'
          : '用于区分订单业务归属;选「客户订单」时才需关联来源单据与客户。',
      rules: 'selectRequired',
    },
    {
@@ -142,6 +146,7 @@
        show: (values) =>
          values.orderSourceType === MesProWorkOrderSourceTypeEnum.ORDER,
      },
      help: '填写上游单据(如销售订单)的编号,用于追溯该订单的来源。',
    },
    {
      fieldName: 'type',
@@ -161,6 +166,11 @@
          }
        },
      },
      help: (values: { type?: number }) =>
        values.type === MesProWorkOrderTypeEnum.OUTSOURCE ||
        values.type === MesProWorkOrderTypeEnum.PURCHASE
          ? '已选委外/采购生产:需指定供应商,委外报工与入库将按该供应商结算。'
          : '自营生产按本单工序排产;选「委外加工」或「采购生产」时需额外指定供应商。',
      rules: 'selectRequired',
    },
    {
@@ -171,6 +181,7 @@
        disabled: headerReadonly,
        placeholder: '请选择产品',
      },
      help: '选择产品后,工序将自动从其工艺路线带出并按需调整;产品需先在「工艺流程」中配置路线与投料 BOM。',
      rules: 'selectRequired',
    },
    {
@@ -199,6 +210,7 @@
        show: (values) =>
          values.orderSourceType === MesProWorkOrderSourceTypeEnum.ORDER,
      },
      help: '关联客户后,订单可在客户维度追溯并对接后续结算。',
    },
    {
      fieldName: 'vendorId',
@@ -214,6 +226,7 @@
          values.type === MesProWorkOrderTypeEnum.OUTSOURCE ||
          values.type === MesProWorkOrderTypeEnum.PURCHASE,
      },
      help: '委外/采购订单需指定供应商,委外报工与入库均以该供应商为准。',
    },
    {
      fieldName: 'batchCode',
@@ -235,6 +248,7 @@
        placeholder: '请选择需求日期',
        valueFormat: 'YYYY-MM-DD',
      },
      help: '预计交付/完成日期,用于排产、进度跟踪与交期预警。',
      rules: 'required',
    },
    // {
@@ -262,6 +276,7 @@
        disabled: headerReadonly,
      },
      defaultValue: false,
      help: '开启后,生产报工提交需经审批通过才计入产量;关闭则报工直接生效。',
      rules: 'required',
    },
    {
@@ -285,6 +300,7 @@
          return undefined;
        },
      },
      help: '选择报工提交后走的审批流程;流程需先在系统「流程管理」中部署,仅「报工审批」类流程可选。',
    },
    {
src/views/mes/pro/workorder/modules/form.vue
@@ -353,6 +353,9 @@
        <Tabs.TabPane key="process"
                      tab="工序"
                      forceRender>
          <p class="mb-3 text-xs text-gray-400 leading-relaxed">
            æ–°å¢žæ¨¡å¼ä¼šè‡ªåŠ¨ä»Žæ‰€é€‰äº§å“çš„å·¥è‰ºè·¯çº¿å¸¦å‡ºå·¥åºï¼Œç‚¹å‡»è¡Œé¦–æ‰‹æŸ„å¯æ‹–æ‹½è°ƒæ•´é¡ºåºï¼›ã€Œå…³é”®å·¥åºã€éœ€é‡ç‚¹ç®¡æŽ§ã€ã€Œè´¨æ£€å·¥åºã€ç”±è´¨æ£€äººå‘˜æ£€éªŒã€å‹¾é€‰ã€Œå€’å†²ã€çš„å·¥åºåœ¨æŠ¥å·¥åŽæŒ‰æŠ•æ–™ç”¨é‡è‡ªåŠ¨æ‰£å‡ç‰©æ–™åº“å­˜ã€‚æ¯é“å·¥åºå¯ç‚¹ã€Œæ–°å¢žç‰©æ–™ã€é…ç½®æŠ•æ–™æ˜Žç»†ã€‚
          </p>
          <ProcessList
            ref="processListRef"
            :form-type="formType"
@@ -364,6 +367,9 @@
        </Tabs.TabPane>
        <Tabs.TabPane key="item"
                      tab="物料需求">
          <p class="mb-3 text-xs text-gray-400 leading-relaxed">
            ç‰©æ–™éœ€æ±‚按各工序「投料明细」×订单数量自动汇总生成,仅供查看;如需调整,请回到「工序」页签维护投料。
          </p>
          <ItemList v-if="formData?.id"
                    :work-order-id="formData.id" />
          <div v-else class="text-gray-400 text-center py-8">
@@ -374,12 +380,16 @@
    </template>
    <template #prepend-footer>
      <div class="flex flex-auto flex-col gap-2">
        <div v-if="canUseAi" class="flex items-center gap-2">
        <div v-if="canUseAi" class="flex flex-wrap items-center gap-x-2 gap-y-1">
          <span class="text-xs font-semibold text-gray-800 dark:text-gray-100">AI æ™ºèƒ½åˆ†æž</span>
          <div class="flex items-center gap-2">
          <PredictMaterial :work-order-id="formData!.id" />
          <PredictDuration :work-order-id="formData!.id" />
          <PredictRisk :work-order-id="formData!.id" />
          <PredictDelivery :work-order-id="formData!.id" />
        </div>
          <span class="text-xs text-gray-400">基于历史生产数据估算,仅供参考,不参与业务计算</span>
        </div>
        <div v-if="canUseAi && showMainBtn" class="border-t border-gray-200" />
        <div class="flex items-center">
          <Button v-if="showMainBtn"
src/views/mes/pro/workorder/modules/process-list.vue
@@ -346,6 +346,9 @@
      width="500px"
      @ok="handleQuantityConfirm"
    >
      <p class="mb-3 text-xs text-gray-400 leading-relaxed">
        ç”¨æ–™æ¯”例 = ç”Ÿäº§ 1 ä»¶äº§å“æ¶ˆè€—该物料的数量;「倒冲」工序将在报工后据此自动扣减库存。
      </p>
      <div class="space-y-3 py-2">
        <div
          v-for="(item, idx) in pendingItems"
src/views/mes/process-design/route/data.ts
@@ -410,8 +410,11 @@
  ];
}
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(onItemChange?: (item: any) => void): VbenFormSchema[] {
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å•ï¼›multiple = true(新增模式)时产品可多选,一次性绑定多个 */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  multiple = false,
): VbenFormSchema[] {
  return [
    {
      fieldName: "id",
@@ -423,8 +426,20 @@
      component: "Input",
      dependencies: { triggerFields: [""], show: () => false },
    },
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
    // æ–°å¢žï¼ˆå¤šé€‰ï¼‰ï¼šä¸€æ¬¡ç»‘定多个产品;编辑(单选):change å›žå¡«ç¼–码/名称/规格/单位
    multiple
      ? {
          fieldName: "itemIds",
          label: "产品(可多选)",
          component: markRaw(MdItemSelect),
          componentProps: {
            multiple: true,
            placeholder: "请选择要绑定的产品(可多选)",
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        }
      : {
      fieldName: "itemId",
      label: "产品",
      component: markRaw(MdItemSelect),
src/views/mes/process-design/route/modules/form.vue
@@ -2,24 +2,28 @@
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { confirm, useVbenModal } from '@vben/common-ui';
import { message, TabPane, Tabs } from 'ant-design-vue';
import { Button, Divider, message, Select } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import {
  deleteRouteProduct,
  getRouteProductListByRoute,
} from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
import ProductForm from './product-form.vue';
import Workbench from './workbench.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create'); // è¡¨å•模式
const subTab = ref('process'); // å½“前激活的子表 Tab
const formData = ref<MesProRouteApi.Route>();
const isDetail = computed(() => formType.value === 'detail'); // æ˜¯å¦æŸ¥çœ‹æ¨¡å¼
@@ -32,6 +36,72 @@
    : $t('ui.actionTitle.create', ['工艺路线']);
});
// ==================== äº§å“ç»‘定 ====================
const products = ref<MesProRouteProductApi.RouteProduct[]>([]); // å…³è”产品列表
const selectedProductItemId = ref<number>(); // å½“前选中的产品物料编号
const selectedProduct = computed(() =>
  products.value.find((item) => item.itemId === selectedProductItemId.value),
);
const productOptions = computed(() =>
  products.value.map((item) => ({ label: item.itemName, value: item.itemId })),
);
async function loadProducts() {
  const routeId = formData.value?.id;
  if (!routeId) {
    products.value = [];
    return;
  }
  products.value = (await getRouteProductListByRoute(routeId)) || [];
  if (
    !products.value.some((item) => item.itemId === selectedProductItemId.value)
  ) {
    selectedProductItemId.value = products.value[0]?.itemId;
  }
}
const [ProductFormModal, productFormModalApi] = useVbenModal({
  connectedComponent: ProductForm,
  destroyOnClose: true,
});
/** æ‰“开产品弹窗(绑定 / ç¼–辑) */
function openProductForm(row?: MesProRouteProductApi.RouteProduct) {
  if (!formData.value?.id) {
    return;
  }
  productFormModalApi
    .setData(
      row
        ? { id: row.id, routeId: formData.value.id }
        : {
            routeId: formData.value.id,
            boundItemIds: products.value
              .map((item) => item.itemId)
              .filter((id): id is number => id != null),
          },
    )
    .open();
}
/** ç§»é™¤äº§å“ï¼ˆä¼šçº§è”删除该产品的投入物料配置) */
async function handleRemoveProduct() {
  if (!selectedProduct.value?.id) {
    return;
  }
  try {
    await confirm(
      `确认将产品「${selectedProduct.value.itemName}」从该工艺路线移除?其全部投入物料配置将一并删除。`,
    );
  } catch {
    return;
  }
  await deleteRouteProduct(selectedProduct.value.id);
  message.success($t('ui.actionMessage.operationSuccess'));
  await loadProducts();
}
// ==================== åŸºæœ¬ä¿¡æ¯è¡¨å• ====================
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
@@ -59,11 +129,12 @@
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    try {
      if (formType.value === 'create') {
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤ç»„æˆå·¥åºã€å…³è”äº§å“
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œè¿›å…¥ã€Œäº§å“ä¸Žå·¥åºé…ç½®ã€å·¥ä½œå°
        const id = await createRoute(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        formType.value = 'update';
        await loadProducts();
      } else {
        await updateRoute(data);
        formData.value = { ...formData.value, ...data };
@@ -82,10 +153,11 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      products.value = [];
      selectedProductItemId.value = undefined;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    subTab.value = 'process';
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
@@ -99,6 +171,7 @@
      formData.value = await getRoute(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await loadProducts();
    } finally {
      modalApi.unlock();
    }
@@ -107,20 +180,49 @@
</script>
<template>
  <Modal :title="getTitle" class="w-4/5">
  <Modal :title="getTitle" class="w-11/12">
    <Form class="mx-4" />
    <!-- ç¼–辑/详情模式下展示子表 Tab,新增模式下隐藏 -->
    <Tabs
      v-if="formType !== 'create' && formData?.id"
      v-model:active-key="subTab"
      class="mx-4 mt-4"
    <!-- ç¼–辑/详情模式下展示「产品与工序配置」工作台,新增模式下隐藏 -->
    <template v-if="formData?.id">
      <div class="mx-4 mt-4">
        <Divider class="!my-2" orientation="left">
          <span class="text-sm">产品与工序配置</span>
        </Divider>
        <div class="flex flex-wrap items-center gap-3">
          <span class="shrink-0 text-sm font-medium">生产产品</span>
          <Select
            v-model:value="selectedProductItemId"
            class="!w-64"
            allow-clear
            :disabled="isDetail"
            placeholder="请选择或绑定产品"
            :options="productOptions"
          />
          <template v-if="!isDetail">
            <Button type="primary" @click="openProductForm()"> ç»‘定产品 </Button>
            <Button
              :disabled="!selectedProduct"
              @click="openProductForm(selectedProduct)"
    >
      <TabPane key="process" tab="组成工序">
        <ProcessList :form-type="formType" :route-id="formData.id" />
      </TabPane>
      <TabPane key="product" tab="关联产品">
        <ProductList :form-type="formType" :route-id="formData.id" />
      </TabPane>
    </Tabs>
              ç¼–辑产品
            </Button>
            <Button danger :disabled="!selectedProduct" @click="handleRemoveProduct">
              ç§»é™¤äº§å“
            </Button>
          </template>
        </div>
        <p v-if="products.length === 0" class="mt-2 text-xs text-gray-400">
          å…ˆç»‘定要生产的物料,再为每道工序配置「投入 + äº§å‡ºã€
        </p>
        <div class="mt-3">
          <Workbench
            :form-type="formType"
            :product="selectedProduct"
            :route-id="formData.id"
          />
        </div>
      </div>
    </template>
  </Modal>
  <ProductFormModal @success="loadProducts" />
</template>
src/views/mes/process-design/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { Divider, message } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,17 +16,17 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
const boundItemIds = ref<number[]>([]); // å·²ç»‘定本路线的产品物料编号(新增多选时跳过)
const getTitle = computed(() =>
  formData.value?.id
    ? $t('ui.actionTitle.edit', ['工艺路线产品'])
    : $t('ui.actionTitle.create', ['工艺路线产品']),
);
/** MdItemSelect change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
/** MdItemSelect å•选 change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
async function handleItemChange(item?: any) {
  if (!item) {
    return;
@@ -58,22 +58,62 @@
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesProRouteProductApi.RouteProduct;
    const data = (await formApi.getValues()) as MesProRouteProductApi.RouteProduct & {
      itemIds?: number[];
    };
    try {
      // ç¼–辑模式:更新单个产品
      if (formData.value?.id) {
        await updateRouteProduct(data);
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
      }
        await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
        return;
      }
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ä¸€æ¬¡æ€§ç»‘定,跳过已在路线中的产品
      const itemIds = Array.isArray(data.itemIds) ? data.itemIds : [];
      if (itemIds.length === 0) {
        message.warning('请至少选择一个产品');
        return;
      }
      let created = 0;
      let skipped = 0;
      const failedIds: number[] = [];
      for (const itemId of itemIds) {
        if (itemId == null) {
          continue;
        }
        if (boundItemIds.value.includes(itemId)) {
          skipped++;
          continue;
        }
        try {
          await createRouteProduct({
            routeId: data.routeId,
            itemId,
            quantity: data.quantity,
            productionTime: data.productionTime,
            timeUnitType: data.timeUnitType,
            remark: data.remark,
          });
          created++;
        } catch {
          failedIds.push(itemId);
        }
      }
      if (skipped > 0) {
        message.warning(`已跳过 ${skipped} ä¸ªå·²ç»‘定的产品`);
      }
      if (failedIds.length > 0) {
        message.error(
          `有 ${failedIds.length} ä¸ªäº§å“ç»‘定失败(可能已被其它工艺路线占用)`,
        );
      }
      if (created > 0) {
        message.success(`成功绑定 ${created} ä¸ªäº§å“`);
        await modalApi.close();
        emit('success');
      }
    } finally {
      modalApi.unlock();
    }
@@ -81,24 +121,42 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      boundItemIds.value = [];
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      routeId: number;
      boundItemIds?: number[];
    }>();
    if (!data) {
      return;
    }
    boundItemIds.value = data.boundItemIds || [];
    if (!data.id) {
      await formApi.setValues({ routeId: data.routeId });
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ç»‘定
      formData.value = undefined;
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, true),
      });
      await formApi.setValues({
        routeId: data.routeId,
        itemIds: [],
        quantity: 1,
        productionTime: 1,
        timeUnitType: 'MINUTE',
        remark: undefined,
      });
      return;
    }
    // ç¼–辑模式:加载单个产品回填
    modalApi.lock();
    try {
      formData.value = await getRouteProduct(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, false),
      });
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
@@ -108,18 +166,7 @@
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
  <Modal :title="getTitle" class="w-2/5">
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
src/views/mes/process-design/route/modules/workbench.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,545 @@
<script lang="ts" setup>
import type { MesMdItemApi } from '#/api/mes/md/item';
import type { MesMdProductBomApi } from '#/api/mes/md/item/productBom';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import {
  Button,
  Card,
  Empty,
  Input,
  InputNumber,
  message,
  Popconfirm,
  Table,
} from 'ant-design-vue';
import {
  deleteRouteProcess,
  getRouteProcessListByRoute,
  updateRouteProcess,
} from '#/api/mes/pro/route/process';
import {
  createRouteProductBom,
  deleteRouteProductBom,
  getRouteProductBomList,
  updateRouteProductBom,
} from '#/api/mes/pro/route/productbom';
import { $t } from '#/locales';
import { MdItemSelect, MdProductBomSelect } from '#/views/mes/md/item/components';
import type { FormType } from '../data';
import ProcessForm from './process-form.vue';
defineOptions({ name: 'MesRouteWorkbench' });
const props = withDefaults(
  defineProps<{
    routeId: number;
    formType: FormType;
    product?: MesProRouteProductApi.RouteProduct;
  }>(),
  { product: undefined },
);
const editable = computed(() => props.formType !== 'detail');
const processes = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥åºåˆ—表
const selectedProcessId = ref<number>(); // å½“前选中工序
const outputItemIds = ref<number[]>([]); // é€‰ä¸­å·¥åºçš„产出半成品
const bomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]); // é€‰ä¸­å·¥åºåœ¨å½“前产品下的投入物料
const adding = ref(false); // æ˜¯å¦æ˜¾ç¤ºæ–°å¢žæŠ•入物料行
const selectedProcess = computed(() =>
  processes.value.find((item) => item.processId === selectedProcessId.value),
);
const productItemId = computed(() => props.product?.itemId);
const linkTypeLabel = computed(() =>
  selectedProcess.value?.linkType != null
    ? getDictLabel(DICT_TYPE.MES_PRO_LINK_TYPE, selectedProcess.value.linkType)
    : '-',
);
const [ProcessFormModal, processFormModalApi] = useVbenModal({
  connectedComponent: ProcessForm,
  destroyOnClose: true,
});
/** åŠ è½½å·¥åºåˆ—è¡¨ï¼ˆä¿æŒé€‰ä¸­ï¼Œå¤±æ•ˆæ—¶å›žé€€ç¬¬ä¸€ä¸ªï¼‰ */
async function loadProcesses() {
  const list = (await getRouteProcessListByRoute(props.routeId)) || [];
  processes.value = list;
  if (list.length === 0) {
    selectedProcessId.value = undefined;
    return;
  }
  if (!list.some((item) => item.processId === selectedProcessId.value)) {
    selectedProcessId.value = list[0]!.processId;
  }
}
/** æ ¹æ®é€‰ä¸­å·¥åºåŒæ­¥å¾…编辑的产出半成品 */
function syncOutputItemIds() {
  outputItemIds.value = selectedProcess.value?.outputItemIds ?? [];
}
/** åŠ è½½é€‰ä¸­å·¥åºåœ¨å½“å‰äº§å“ä¸‹çš„æŠ•å…¥ç‰©æ–™ */
async function loadBomList() {
  const processId = selectedProcess.value?.processId;
  if (!processId || !productItemId.value) {
    bomList.value = [];
    return;
  }
  bomList.value =
    (await getRouteProductBomList({
      routeId: props.routeId,
      processId,
      productId: productItemId.value,
    })) || [];
  // æ¸…理已不在当前投入清单中的行内备注草稿,避免切换后残留旧输入
  const draftIds = new Set(
    bomList.value
      .map((item) => item.id)
      .filter((id): id is number => id != null),
  );
  Object.keys(remarkEditValues.value).forEach((key) => {
    if (!draftIds.has(Number(key))) {
      delete remarkEditValues.value[Number(key)];
    }
  });
}
watch(
  () => props.routeId,
  () => loadProcesses(),
  { immediate: true },
);
watch(
  () => selectedProcessId.value,
  () => {
    syncOutputItemIds();
    loadBomList();
  },
);
watch(
  () => productItemId.value,
  () => {
    outputItemIds.value = [];
    loadBomList();
  },
);
/** æ–°å¢žå·¥åº */
function openProcessCreate() {
  const maxSort = processes.value.length
    ? Math.max(...processes.value.map((item) => item.sort || 0))
    : 0;
  processFormModalApi
    .setData({ maxSort, routeId: props.routeId, formType: props.formType })
    .open();
}
/** ç¼–辑工序信息 */
function openProcessEdit(row: MesProRouteProcessApi.RouteProcess) {
  processFormModalApi
    .setData({ id: row.id, routeId: props.routeId, formType: props.formType })
    .open();
}
/** åˆ é™¤å·¥åº */
async function handleProcessDelete(row: MesProRouteProcessApi.RouteProcess) {
  await deleteRouteProcess(row.id!);
  message.success($t('ui.actionMessage.operationSuccess'));
  await loadProcesses();
}
/** äº§å‡ºåŠæˆå“ï¼šé€‰æ‹©å˜åŒ–即保存 */
async function handleOutputChange(
  items: MesMdItemApi.Item | MesMdItemApi.Item[] | undefined,
) {
  if (!selectedProcess.value) {
    return;
  }
  const ids = (
    Array.isArray(items) ? items : items ? [items] : []
  )
    .map((item) => item.id)
    .filter((id): id is number => id != null);
  outputItemIds.value = ids;
  try {
    await updateRouteProcess({
      id: selectedProcess.value.id,
      routeId: selectedProcess.value.routeId,
      outputItemIds: ids,
    });
    message.success($t('ui.actionMessage.operationSuccess'));
  } catch {
    // ä¿å­˜å¤±è´¥ï¼šé‡è½½å·¥åºåˆ—表回退本地值
    await loadProcesses();
    syncOutputItemIds();
  }
}
/** ç”¨é‡ä¿®æ”¹ï¼šå˜åŒ–即保存(携带完整关联字段,通过后端必填校验) */
async function handleQuantityChange(
  record: MesProRouteProductBomApi.RouteProductBom,
  value: number | string | null,
) {
  if (value == null || record.id == null) {
    return;
  }
  const quantity = Number(value);
  if (Number.isNaN(quantity)) {
    return;
  }
  record.quantity = quantity;
  try {
    await updateRouteProductBom(toUpdatePayload(record));
  } catch {
    await loadBomList();
  }
}
const remarkEditValues = ref<Record<number, string>>({}); // è¡Œå†…备注编辑的暂存值
/** å¤‡æ³¨è¾“入:暂存值,不立即提交 */
function handleRemarkInput(
  record: MesProRouteProductBomApi.RouteProductBom,
  value: string,
) {
  if (record.id != null) {
    remarkEditValues.value[record.id] = value;
  }
}
/** è¯»å–行内备注草稿,无编辑时回退数据库值 */
function getRemarkDraft(record: MesProRouteProductBomApi.RouteProductBom) {
  const draft =
    record.id != null ? remarkEditValues.value[record.id] : undefined;
  return draft ?? record.remark ?? '';
}
/** å¤‡æ³¨ä¿®æ”¹ï¼šå¤±ç„¦/回车即保存 */
async function handleRemarkChange(
  record: MesProRouteProductBomApi.RouteProductBom,
  value: string,
) {
  if (record.id == null) {
    return;
  }
  const remark = typeof value === 'string' ? value.trim() : '';
  // åŒæ­¥æš‚存值,保证回车后再触发的 blur è¯»åˆ°çš„æ˜¯æäº¤åŽçš„值,不会用旧值回写
  remarkEditValues.value[record.id] = remark;
  if (record.remark === remark) {
    return;
  }
  record.remark = remark;
  try {
    await updateRouteProductBom(toUpdatePayload(record));
  } catch {
    await loadBomList();
  }
}
/** è¡Œå†…编辑提交:仅携带后端校验所需的关联字段 */
function toUpdatePayload(record: MesProRouteProductBomApi.RouteProductBom) {
  return {
    id: record.id,
    routeId: record.routeId,
    processId: record.processId,
    productId: record.productId,
    itemId: record.itemId,
    quantity: record.quantity,
    remark: record.remark,
  };
}
/** é€‰ä¸­ BOM åŽæ‰¹é‡å…¥åº“(支持多选,已存在的物料自动跳过) */
async function handleBomSelected(
  boms?: MesMdProductBomApi.ProductBom | MesMdProductBomApi.ProductBom[],
) {
  if (!selectedProcess.value || !productItemId.value) {
    return;
  }
  const selectedList = Array.isArray(boms) ? boms : boms ? [boms] : [];
  if (selectedList.length === 0) {
    return;
  }
  // è·³è¿‡å·²åœ¨æŠ•入清单中的物料,避免重复入库
  const existingIds = new Set(
    bomList.value
      .map((item) => item.itemId)
      .filter((id): id is number => id != null),
  );
  let created = 0;
  for (const bom of selectedList) {
    if (!bom.bomItemId || existingIds.has(bom.bomItemId)) {
      continue;
    }
    await createRouteProductBom({
      routeId: props.routeId,
      processId: selectedProcess.value.processId,
      productId: productItemId.value,
      itemId: bom.bomItemId,
      quantity: bom.quantity ?? 1,
    });
    created++;
  }
  if (created > 0) {
    message.success($t('ui.actionMessage.operationSuccess'));
  }
  adding.value = false;
  await loadBomList();
}
/** åˆ é™¤æŠ•入物料 */
async function handleBomDelete(record: MesProRouteProductBomApi.RouteProductBom) {
  await deleteRouteProductBom(record.id!);
  message.success($t('ui.actionMessage.operationSuccess'));
  await loadBomList();
}
/** æŠ•入物料表格列 */
const columns = [
  { title: '物料', key: 'item' as const, width: 220 },
  { title: '用量', key: 'quantity' as const, width: 110 },
  { title: '单位', key: 'unit' as const, dataIndex: 'unitName', width: 70 },
  { title: '备注', key: 'remark' as const, dataIndex: 'remark', minWidth: 160 },
  { title: '操作', key: 'action' as const, width: 70 },
];
</script>
<template>
  <ProcessFormModal @success="loadProcesses" />
  <div class="flex gap-4">
    <!-- å·¦ä¾§ï¼šå·¥åºåˆ—表 -->
    <div
      class="w-72 shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-white"
    >
      <div
        class="flex h-10 items-center justify-between border-b border-gray-100 bg-gray-50 px-3"
      >
        <span class="text-sm font-medium">工序列表</span>
        <Button
          v-if="editable"
          size="small"
          type="link"
          @click="openProcessCreate"
        >
          æ–°å¢žå·¥åº
        </Button>
      </div>
      <ul class="max-h-[560px] overflow-auto">
        <li
          v-for="item in processes"
          :key="item.processId"
          class="group flex cursor-pointer items-center gap-2 px-3 py-2.5 text-sm transition-colors"
          :class="
            selectedProcess?.processId === item.processId
              ? 'bg-blue-50 text-blue-600'
              : 'hover:bg-gray-50'
          "
          @click="selectedProcessId = item.processId"
        >
          <span class="w-6 shrink-0 text-right text-xs text-gray-400">
            {{ item.sort }}
          </span>
          <span
            class="h-3 w-3 shrink-0 rounded-full border border-gray-200"
            :style="{ backgroundColor: item.colorCode }"
          ></span>
          <span class="min-w-0 flex-1 truncate">{{ item.processName }}</span>
          <span v-if="item.keyFlag" class="shrink-0 text-amber-500" title="关键工序">★</span>
          <span v-if="item.checkFlag" class="shrink-0 text-green-600" title="质检确认">◎</span>
          <span v-if="item.backflushFlag" class="shrink-0 text-cyan-600" title="倒冲">↻</span>
          <Popconfirm
            v-if="editable"
            title="确认删除该工序?"
            ok-text="确定"
            cancel-text="取消"
            @confirm="handleProcessDelete(item)"
            @click.stop
          >
            <Button
              class="opacity-0 transition-opacity group-hover:opacity-100"
              size="small"
              type="text"
              danger
            >
              åˆ 
            </Button>
          </Popconfirm>
        </li>
        <li v-if="processes.length === 0" class="px-3 py-10 text-center text-xs text-gray-400">
          å°šæœªé…ç½®å·¥åºï¼Œç‚¹å‡»å³ä¸Šè§’「新增工序」开始
        </li>
      </ul>
    </div>
    <!-- å³ä¾§ï¼šé€‰ä¸­å·¥åºçš„配置面板 -->
    <div class="min-w-0 flex-1 space-y-3">
      <template v-if="selectedProcess">
        <!-- å·¥åºä¿¡æ¯ -->
        <Card :bordered="false" class="!border border-gray-200">
          <div class="flex items-start justify-between gap-3">
            <div class="min-w-0">
              <div class="flex items-center gap-2">
                <span class="text-base font-medium">
                  {{ selectedProcess.processName }}
                </span>
                <span class="text-xs text-gray-400">
                  {{ selectedProcess.processCode }}
                </span>
                <span v-if="selectedProcess.keyFlag" class="text-amber-500" title="关键工序">★</span>
                <span v-if="selectedProcess.checkFlag" class="text-green-600" title="质检确认">◎</span>
                <span v-if="selectedProcess.backflushFlag" class="text-cyan-600" title="倒冲">↻</span>
              </div>
              <div class="mt-1 flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-500">
                <span>序号:{{ selectedProcess.sort }}</span>
                <span>准备时间:{{ selectedProcess.prepareTime ?? 0 }} åˆ†</span>
                <span>等待时间:{{ selectedProcess.waitTime ?? 0 }} åˆ†</span>
                <span v-if="selectedProcess.linkType != null">
                  ä¸Žä¸‹é“工序:{{ linkTypeLabel }}
                </span>
              </div>
              <div v-if="selectedProcess.remark" class="mt-1 text-xs text-gray-400">
                å¤‡æ³¨ï¼š{{ selectedProcess.remark }}
              </div>
            </div>
            <Button
              v-if="editable"
              type="link"
              @click="openProcessEdit(selectedProcess)"
            >
              ç¼–辑信息
            </Button>
          </div>
        </Card>
        <!-- äº§å‡º -->
        <Card :bordered="false" class="!border border-gray-200">
          <template #title>
            <span class="text-sm font-medium">产出</span>
          </template>
          <MdItemSelect
            :model-value="outputItemIds"
            multiple
            :disabled="!editable"
            placeholder="留空则默认为工单成品"
            @change="handleOutputChange"
          />
          <p class="mt-1 text-xs text-gray-400">
            ç•™ç©ºè¡¨ç¤ºè¯¥å·¥åºäº§å‡ºå·¥å•成品;选择多个表示一个工序产出多种半成品。
          </p>
        </Card>
        <!-- æŠ•入物料 -->
        <Card :bordered="false" class="!border border-gray-200">
          <template #title>
            <span class="text-sm font-medium">
              æŠ•入物料{{ productItemId ? `(${props.product?.itemName})` : '' }}
            </span>
          </template>
          <Empty
            v-if="!productItemId"
            :image="Empty.PRESENTED_IMAGE_SIMPLE"
            description="请先在顶部绑定生产产品"
          />
          <template v-else>
            <Table
              :data-source="bomList"
              :columns="columns"
              :pagination="false"
              size="small"
              row-key="id"
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key === 'item'">
                  <div class="leading-4">
                    <div class="text-sm">{{ record.itemName }}</div>
                    <div class="text-xs text-gray-400">{{ record.itemCode }}</div>
                  </div>
                </template>
                <template v-else-if="column.key === 'quantity'">
                  <InputNumber
                    v-if="editable"
                    :value="record.quantity"
                    :min="0"
                    :precision="2"
                    size="small"
                    class="!w-24"
                    @change="(value) => handleQuantityChange(record, value)"
                  />
                  <span v-else>{{ record.quantity }}</span>
                </template>
                <template v-else-if="column.key === 'remark'">
                  <span v-if="!editable">{{ record.remark }}</span>
                  <Input
                    v-else
                    :value="getRemarkDraft(record)"
                    :maxlength="255"
                    size="small"
                    placeholder="备注"
                    @update:value="(value: string) => handleRemarkInput(record, value)"
                    @blur="() => handleRemarkChange(record, getRemarkDraft(record))"
                    @pressEnter="() => handleRemarkChange(record, getRemarkDraft(record))"
                  />
                </template>
                <template v-else-if="column.key === 'action'">
                  <Popconfirm
                    v-if="editable"
                    title="确认删除该投入物料?"
                    ok-text="确定"
                    cancel-text="取消"
                    @confirm="handleBomDelete(record)"
                  >
                    <Button type="link" size="small" danger> åˆ é™¤ </Button>
                  </Popconfirm>
                </template>
              </template>
            </Table>
            <div class="mt-2">
              <template v-if="editable && adding">
                <div class="flex items-center gap-2">
                  <MdProductBomSelect
                    :item-id="productItemId"
                    multiple
                    placeholder="选择该产品的 BOM ç‰©æ–™ï¼ˆå¯å¤šé€‰ï¼‰"
                    @change="handleBomSelected"
                  />
                  <Button size="small" @click="adding = false">取消</Button>
                </div>
              </template>
              <div v-else-if="editable" class="flex items-center gap-3">
                <Button size="small" type="dashed" block @click="adding = true">
                  ï¼‹ æ–°å¢žæŠ•入物料
                </Button>
                <span v-if="bomList.length === 0" class="text-xs text-orange-500">
                  è¯¥å·¥åºå°šæœªé…ç½®æŠ•入物料;标为「倒冲」的工序报工时会按此处用量自动扣料
                </span>
              </div>
            </div>
          </template>
        </Card>
      </template>
      <!-- æ— å·¥åºç©ºæ€ -->
      <div
        v-else
        class="flex h-56 items-center justify-center rounded-lg border border-dashed border-gray-300 text-sm text-gray-400"
      >
        è¯·å…ˆåœ¨å·¦ä¾§æ–°å¢žå¹¶é€‰ä¸­ä¸€é“工序
      </div>
    </div>
  </div>
</template>