云
6 小时以前 5d321660a2f1a4b8756f816283e96481a8c934e4
src/views/mes/pro/batch/modules/form.vue
@@ -1,11 +1,11 @@
<script lang="ts" setup>
  import type { FormType } from "../data";
  import type { FormType, PalletOption, TonBagOption } from "../data";
  import type { VxeTableGridOptions } from "#/adapter/vxe-table";
  import type { MesProBatchApi } from "#/api/mes/pro/batch";
  import type { SystemUserApi } from "#/api/system/user";
  import { computed, nextTick, ref } from "vue";
  import { computed, nextTick, reactive, ref } from "vue";
  import { useVbenModal } from "@vben/common-ui";
  import { DICT_TYPE } from "@vben/constants";
@@ -21,10 +21,14 @@
    getBatch,
    updateBatch,
  } from "#/api/mes/pro/batch";
  import { getUnboundPalletPage } from "#/api/mes/pro/pallet";
  import { getUnboundTonBagPage } from "#/api/mes/pro/bagCode";
  import { $t } from "#/locales";
  import { UserSelect } from "#/views/system/user/components";
  import {
    applyPalletOptionState,
    applyTonBagOptionState,
    useFormSchema,
    useWorkerEditGridColumns,
    useWorkerReadGridColumns,
@@ -36,6 +40,270 @@
  const formType = ref<FormType>("create");
  const formData = ref<MesProBatchApi.Batch>();
  /** 绑定下拉每次加载的条数 */
  const BINDING_PAGE_SIZE = 50;
  /** 距离下拉底部多少像素时触发加载下一页 */
  const BINDING_SCROLL_THRESHOLD = 32;
  /** 输入框搜索防抖时间(毫秒) */
  const BINDING_SEARCH_DEBOUNCE = 300;
  /**
   * 绑定下拉的分页加载状态。
   *
   * 下拉选项本身就是渲染数据源(保持数组引用不变,直接 splice 更新),
   * 已加载的分页结果放在 loaded 中,跨页选中的选项单独记录,避免换页搜索后标签丢失。
   */
  interface BindingLoader<T extends PalletOption> {
    hasMore: boolean;
    keyword: string;
    loaded: T[];
    loading: boolean;
    pageNo: number;
    seq: number;
    timer?: ReturnType<typeof setTimeout>;
  }
  function createBindingLoader<T extends PalletOption>(): BindingLoader<T> {
    return {
      hasMore: true,
      keyword: "",
      loaded: [],
      loading: false,
      pageNo: 0,
      seq: 0,
    };
  }
  const palletOptions = reactive<PalletOption[]>([]);
  const tonBagOptions = reactive<TonBagOption[]>([]);
  const palletSelected = new Map<number, PalletOption>();
  const tonBagSelected = new Map<number, TonBagOption>();
  const palletLoader = createBindingLoader<PalletOption>();
  const tonBagLoader = createBindingLoader<TonBagOption>();
  /** 已加载分页结果 + 跨页选中项合并为下拉渲染数据 */
  function renderBindingOptions<T extends PalletOption>(
    target: T[],
    loader: BindingLoader<T>,
    selected: Map<number, T>
  ) {
    const merged = [...loader.loaded];
    const loadedIds = new Set(loader.loaded.map((option) => option.value));
    selected.forEach((option, id) => {
      if (!loadedIds.has(id)) merged.push(option);
    });
    target.splice(0, target.length, ...merged);
  }
  /** 同步选中项(换页搜索后仍需展示已选标签) */
  function syncBindingSelection<T extends PalletOption>(
    target: T[],
    loader: BindingLoader<T>,
    selected: Map<number, T>,
    ids: number[]
  ) {
    const previous = new Map(selected);
    selected.clear();
    ids.forEach((id) => {
      const option = previous.get(id)
        ?? loader.loaded.find((item) => item.value === id);
      if (option) selected.set(id, option);
    });
    renderBindingOptions(target, loader, selected);
  }
  /** 选项禁用态依赖当前表单值,每次加载新一页后重新计算 */
  async function refreshBindingOptionState() {
    const values = await formApi.getValues();
    const existingBinding = formData.value?.bindingMode;
    applyPalletOptionState(palletOptions, values, existingBinding);
    applyTonBagOptionState(tonBagOptions, values, existingBinding);
  }
  /** 拉取一页绑定候选项;reset=true 表示从第一页重新开始(首次打开、搜索) */
  async function loadBindingPage<T extends PalletOption>(
    loader: BindingLoader<T>,
    target: T[],
    selected: Map<number, T>,
    fetchPage: (params: {
      keyword?: string;
      pageNo: number;
      pageSize: number;
    }) => Promise<{ list: T[]; total: number }>,
    reset: boolean
  ) {
    if (!reset && (loader.loading || !loader.hasMore)) return;
    const seq = ++loader.seq;
    loader.loading = true;
    try {
      const pageNo = reset ? 1 : loader.pageNo + 1;
      const page = await fetchPage({
        keyword: loader.keyword || undefined,
        pageNo,
        pageSize: BINDING_PAGE_SIZE,
      });
      // 已被更新的搜索或并发请求覆盖,丢弃本次结果
      if (seq !== loader.seq) return;
      loader.loaded = reset ? page.list : [...loader.loaded, ...page.list];
      loader.pageNo = pageNo;
      loader.hasMore = loader.loaded.length < page.total;
      renderBindingOptions(target, loader, selected);
      await refreshBindingOptionState();
    } finally {
      if (seq === loader.seq) loader.loading = false;
    }
  }
  function fetchPalletPage(params: {
    keyword?: string;
    pageNo: number;
    pageSize: number;
  }) {
    return getUnboundPalletPage(params).then((page) => ({
      list: page.list.map((pallet) => ({
        label: `${pallet.tempCode ?? `托盘${pallet.id}`} · ${pallet.itemName || "待完善品种"}(已装 ${pallet.bagCount ?? 0} 袋)`,
        value: pallet.id as number,
        itemId: pallet.itemId,
        itemName: pallet.itemName,
        itemCode: pallet.itemCode,
      })),
      total: page.total,
    }));
  }
  function fetchTonBagPage(params: {
    keyword?: string;
    pageNo: number;
    pageSize: number;
  }) {
    return getUnboundTonBagPage(params).then((page) => ({
      list: page.list.map((bag) => ({
        label: `${bag.code ?? `吨袋${bag.id}`} · ${bag.itemName || "待完善品种"}`,
        value: bag.id as number,
        itemId: bag.itemId,
        itemName: bag.itemName,
        itemCode: bag.itemCode,
      })),
      total: page.total,
    }));
  }
  function loadPalletPage(reset: boolean) {
    return loadBindingPage(palletLoader, palletOptions, palletSelected, fetchPalletPage, reset);
  }
  function loadTonBagPage(reset: boolean) {
    return loadBindingPage(tonBagLoader, tonBagOptions, tonBagSelected, fetchTonBagPage, reset);
  }
  /** 重置下拉分页状态(每次打开弹窗调用),同时让在途请求的结果失效 */
  function resetBindingLoader<T extends PalletOption>(
    loader: BindingLoader<T>,
    target: T[],
    selected: Map<number, T>
  ) {
    clearTimeout(loader.timer);
    loader.seq += 1;
    loader.hasMore = true;
    loader.keyword = "";
    loader.loaded = [];
    loader.loading = false;
    loader.pageNo = 0;
    target.splice(0, target.length);
    selected.clear();
  }
  function isPopupNearBottom(event: Event) {
    const element = event.target as HTMLElement;
    return element.scrollHeight - element.scrollTop - element.clientHeight <= BINDING_SCROLL_THRESHOLD;
  }
  function scheduleBindingSearch<T extends PalletOption>(
    loader: BindingLoader<T>,
    keyword: string,
    reload: (reset: boolean) => Promise<void>
  ) {
    loader.keyword = keyword.trim();
    clearTimeout(loader.timer);
    loader.timer = setTimeout(() => void reload(true), BINDING_SEARCH_DEBOUNCE);
  }
  function handlePalletSearch(keyword: string) {
    scheduleBindingSearch(palletLoader, keyword, loadPalletPage);
  }
  function handleTonBagSearch(keyword: string) {
    scheduleBindingSearch(tonBagLoader, keyword, loadTonBagPage);
  }
  function handlePalletPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) void loadPalletPage(false);
  }
  function handleTonBagPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) void loadTonBagPage(false);
  }
  const palletHandlers = {
    onChange: handlePalletChange,
    onPopupScroll: handlePalletPopupScroll,
    onSearch: handlePalletSearch,
  };
  const tonBagHandlers = {
    onChange: handleTonBagChange,
    onPopupScroll: handleTonBagPopupScroll,
    onSearch: handleTonBagSearch,
  };
  async function handlePalletChange(ids: number[] = []) {
    const values = await formApi.getValues();
    if (ids.length && (values.tonBagIds?.length || formData.value?.bindingMode === 'tonBag')) {
      message.warning('绑定托盘和绑定吨袋只能选择一种');
      await formApi.setFieldValue('palletIds', []);
      syncBindingSelection(palletOptions, palletLoader, palletSelected, []);
      return;
    }
    const selected = ids
      .map((id) => palletSelected.get(id) ?? palletLoader.loaded.find((option) => option.value === id))
      .filter((option): option is PalletOption => !!option);
    const source = formData.value?.itemId ? formData.value : selected[0];
    let finalIds = ids;
    if (selected.some(option => !option.itemId || (source?.itemId && option.itemId !== source.itemId))) {
      message.warning('生产批次只能绑定相同品种的托盘');
      finalIds = selected.filter(option => option.itemId === source?.itemId).map(option => option.value);
      await formApi.setFieldValue('palletIds', finalIds);
    }
    syncBindingSelection(palletOptions, palletLoader, palletSelected, finalIds);
    await refreshBindingOptionState();
    await formApi.setFieldValue('itemId', source?.itemId);
    await formApi.setFieldValue('itemName', source?.itemName);
    await formApi.setFieldValue('itemCode', source?.itemCode);
  }
  async function handleTonBagChange(ids: number[] = []) {
    const values = await formApi.getValues();
    if (ids.length && (values.palletIds?.length || formData.value?.bindingMode === 'pallet')) {
      message.warning('绑定托盘和绑定吨袋只能选择一种');
      await formApi.setFieldValue('tonBagIds', []);
      syncBindingSelection(tonBagOptions, tonBagLoader, tonBagSelected, []);
      return;
    }
    const selected = ids
      .map((id) => tonBagSelected.get(id) ?? tonBagLoader.loaded.find((option) => option.value === id))
      .filter((option): option is TonBagOption => !!option);
    const source = formData.value?.itemId ? formData.value : selected[0];
    let finalIds = ids;
    if (selected.some(option => option.itemId !== source?.itemId)) {
      message.warning('生产批次只能绑定相同品种的吨袋');
      finalIds = selected.filter(option => option.itemId === source?.itemId).map(option => option.value);
      await formApi.setFieldValue('tonBagIds', finalIds);
    }
    syncBindingSelection(tonBagOptions, tonBagLoader, tonBagSelected, finalIds);
    await refreshBindingOptionState();
    await formApi.setFieldValue('itemId', source?.itemId);
    await formApi.setFieldValue('itemName', source?.itemName);
    await formApi.setFieldValue('itemCode', source?.itemCode);
  }
  const isEditable = computed(() =>
    ["create", "update", "fill"].includes(formType.value)
@@ -90,8 +358,9 @@
  let workerRowSeq = 0;
  /** 添加人员行 */
  function handleAddWorker() {
    workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1);
  async function handleAddWorker() {
    await waitWorkerGridReady();
    await workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1);
  }
  /** 删除人员行 */
@@ -185,6 +454,10 @@
      modalApi.lock();
      try {
        const data = (await formApi.getValues()) as MesProBatchApi.Batch;
        if (data.palletIds?.length && data.tonBagIds?.length) {
          message.warning('绑定托盘和绑定吨袋只能选择一种');
          return;
        }
        if (formType.value === "fill") {
          await fillBatchInfo({ ...data, id: formData.value!.id, workers });
        } else if (formData.value?.id) {
@@ -206,7 +479,25 @@
      }
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      formApi.setState({ schema: useFormSchema(formType.value) });
      // 每次打开重置绑定候选,下拉改为滚动分页加载,首批在表单挂载后异步拉取
      resetBindingLoader(palletLoader, palletOptions, palletSelected);
      resetBindingLoader(tonBagLoader, tonBagOptions, tonBagSelected);
      formApi.setState({
        schema: useFormSchema(
          formType.value,
          palletOptions,
          palletHandlers,
          tonBagOptions,
          tonBagHandlers
        ),
      });
      if (formType.value !== "detail") {
        // 清空上次打开时勾选的托盘/吨袋,避免残留值误触发「批次进入生产中」
        await formApi.setFieldValue("palletIds", undefined);
        await formApi.setFieldValue("tonBagIds", undefined);
        void loadPalletPage(true);
        void loadTonBagPage(true);
      }
      // 根据表单态切换人员子表列
      workerGridApi.setGridOptions({
        columns: isEditable.value
@@ -217,7 +508,11 @@
        modalApi.lock();
        try {
          formData.value = await getBatch(data.id);
          const existingBinding = formData.value.bindingMode;
          formApi.setState({ schema: useFormSchema(formType.value, palletOptions, palletHandlers, tonBagOptions, tonBagHandlers, existingBinding) });
          await formApi.setValues(formData.value);
          // 已有绑定会锁定另一侧下拉,选项禁用态需要按最新表单值重算
          await refreshBindingOptionState();
          const workers = (formData.value.workers ?? []).map(
            (worker, index) => ({ ...worker, _rowKey: worker.id ?? index })
          );
@@ -229,6 +524,7 @@
      } else {
        await waitWorkerGridReady();
        await workerGridApi.grid.loadData([]);
        await handleAddWorker();
      }
    },
  });