云
5 小时以前 5d321660a2f1a4b8756f816283e96481a8c934e4
src/views/mes/pro/batch/modules/form.vue
@@ -21,12 +21,14 @@
    getBatch,
    updateBatch,
  } from "#/api/mes/pro/batch";
  import { getUnboundPalletList } from "#/api/mes/pro/pallet";
  import { getUnboundTonBags } from "#/api/mes/pro/bagCode";
  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,
@@ -38,38 +40,177 @@
  const formType = ref<FormType>("create");
  const formData = ref<MesProBatchApi.Batch>();
  const BINDING_OPTION_PAGE_SIZE = 50;
  /** 绑定下拉每次加载的条数 */
  const BINDING_PAGE_SIZE = 50;
  /** 距离下拉底部多少像素时触发加载下一页 */
  const BINDING_SCROLL_THRESHOLD = 32;
  let palletOptionSource: PalletOption[] = [];
  let tonBagOptionSource: TonBagOption[] = [];
  /** 输入框搜索防抖时间(毫秒) */
  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[]>([]);
  let palletKeyword = "";
  let tonBagKeyword = "";
  const palletSelected = new Map<number, PalletOption>();
  const tonBagSelected = new Map<number, TonBagOption>();
  const palletLoader = createBindingLoader<PalletOption>();
  const tonBagLoader = createBindingLoader<TonBagOption>();
  function filterBindingOptions<T extends PalletOption>(source: T[], keyword: string): T[] {
    const normalized = keyword.trim().toLowerCase();
    if (!normalized) return source;
    return source.filter((option) => option.label.toLowerCase().includes(normalized));
  /** 已加载分页结果 + 跨页选中项合并为下拉渲染数据 */
  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 resetBindingOptions<T extends PalletOption>(
  /** 同步选中项(换页搜索后仍需展示已选标签) */
  function syncBindingSelection<T extends PalletOption>(
    target: T[],
    source: T[],
    keyword = ""
    loader: BindingLoader<T>,
    selected: Map<number, T>,
    ids: number[]
  ) {
    const filtered = filterBindingOptions(source, keyword);
    target.splice(0, target.length, ...filtered.slice(0, BINDING_OPTION_PAGE_SIZE));
    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);
  }
  function appendBindingOptions<T extends PalletOption>(
  /** 选项禁用态依赖当前表单值,每次加载新一页后重新计算 */
  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[],
    source: T[],
    keyword: string
    selected: Map<number, T>,
    fetchPage: (params: {
      keyword?: string;
      pageNo: number;
      pageSize: number;
    }) => Promise<{ list: T[]; total: number }>,
    reset: boolean
  ) {
    const filtered = filterBindingOptions(source, keyword);
    if (target.length >= filtered.length) return;
    target.push(...filtered.slice(target.length, target.length + BINDING_OPTION_PAGE_SIZE));
    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) {
@@ -77,26 +218,30 @@
    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) {
    palletKeyword = keyword;
    resetBindingOptions(palletOptions, palletOptionSource, keyword);
    scheduleBindingSearch(palletLoader, keyword, loadPalletPage);
  }
  function handleTonBagSearch(keyword: string) {
    tonBagKeyword = keyword;
    resetBindingOptions(tonBagOptions, tonBagOptionSource, keyword);
    scheduleBindingSearch(tonBagLoader, keyword, loadTonBagPage);
  }
  function handlePalletPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) {
      appendBindingOptions(palletOptions, palletOptionSource, palletKeyword);
    }
    if (isPopupNearBottom(event)) void loadPalletPage(false);
  }
  function handleTonBagPopupScroll(event: Event) {
    if (isPopupNearBottom(event)) {
      appendBindingOptions(tonBagOptions, tonBagOptionSource, tonBagKeyword);
    }
    if (isPopupNearBottom(event)) void loadTonBagPage(false);
  }
  const palletHandlers = {
@@ -109,19 +254,27 @@
    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 = palletOptionSource.filter(option => ids.includes(option.value));
    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('生产批次只能绑定相同品种的托盘');
      await formApi.setFieldValue('palletIds', selected.filter(option => option.itemId === source?.itemId).map(option => option.value));
      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);
@@ -132,14 +285,21 @@
    if (ids.length && (values.palletIds?.length || formData.value?.bindingMode === 'pallet')) {
      message.warning('绑定托盘和绑定吨袋只能选择一种');
      await formApi.setFieldValue('tonBagIds', []);
      syncBindingSelection(tonBagOptions, tonBagLoader, tonBagSelected, []);
      return;
    }
    const selected = tonBagOptionSource.filter(option => ids.includes(option.value));
    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('生产批次只能绑定相同品种的吨袋');
      await formApi.setFieldValue('tonBagIds', selected.filter(option => option.itemId === source?.itemId).map(option => option.value));
      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);
@@ -319,14 +479,9 @@
      }
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      // 新增/补录/编辑态加载未绑定批次的托盘,供「绑定托盘」多选下拉使用
      palletOptionSource = [];
      tonBagOptionSource = [];
      palletOptions.splice(0);
      tonBagOptions.splice(0);
      palletKeyword = "";
      tonBagKeyword = "";
      // 先挂载基础表单,候选托盘/吨袋在后台加载,避免只显示人员配置的空窗期
      // 每次打开重置绑定候选,下拉改为滚动分页加载,首批在表单挂载后异步拉取
      resetBindingLoader(palletLoader, palletOptions, palletSelected);
      resetBindingLoader(tonBagLoader, tonBagOptions, tonBagSelected);
      formApi.setState({
        schema: useFormSchema(
          formType.value,
@@ -337,34 +492,11 @@
        ),
      });
      if (formType.value !== "detail") {
        const [pallets, tonBags] = await Promise.all([getUnboundPalletList(), getUnboundTonBags()]);
        palletOptionSource = pallets.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,
        }));
        tonBagOptionSource = tonBags.map((bag) => ({
          label: `${bag.code ?? `吨袋${bag.id}`} · ${bag.itemName || '待完善品种'}`,
          value: bag.id as number,
          itemId: bag.itemId, itemName: bag.itemName, itemCode: bag.itemCode,
        }));
        resetBindingOptions(palletOptions, palletOptionSource);
        resetBindingOptions(tonBagOptions, tonBagOptionSource);
      }
      // 重新同步一次 Schema,使依赖字段中的动态 options 立即拿到首批候选项
      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({
@@ -379,6 +511,8 @@
          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 })
          );