| | |
| | | <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"; |
| | |
| | | |
| | | import { useVbenForm } from "#/adapter/form"; |
| | | import { TableAction, useVbenVxeGrid } from "#/adapter/vxe-table"; |
| | | import { createBatch, getBatch, updateBatch } from "#/api/mes/pro/batch"; |
| | | import { |
| | | createBatch, |
| | | fillBatchInfo, |
| | | 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, |
| | |
| | | |
| | | 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"].includes(formType.value) |
| | | ["create", "update", "fill"].includes(formType.value) |
| | | ); |
| | | |
| | | const getTitle = computed(() => { |
| | |
| | | } |
| | | case "update": { |
| | | return $t("ui.actionTitle.edit", ["生产批次"]); |
| | | } |
| | | case "fill": { |
| | | return "补录批次信息"; |
| | | } |
| | | default: { |
| | | return $t("ui.actionTitle.create", ["生产批次"]); |
| | |
| | | let workerRowSeq = 0; |
| | | |
| | | /** 添加人员行 */ |
| | | function handleAddWorker() { |
| | | workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1); |
| | | async function handleAddWorker() { |
| | | await waitWorkerGridReady(); |
| | | await workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1); |
| | | } |
| | | |
| | | /** 删除人员行 */ |
| | |
| | | modalApi.lock(); |
| | | try { |
| | | const data = (await formApi.getValues()) as MesProBatchApi.Batch; |
| | | if (formData.value?.id) { |
| | | 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) { |
| | | await updateBatch({ ...data, id: formData.value.id, workers }); |
| | | } else { |
| | | await createBatch({ ...data, workers }); |
| | |
| | | } |
| | | 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 |
| | |
| | | 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 }) |
| | | ); |
| | |
| | | } else { |
| | | await waitWorkerGridReady(); |
| | | await workerGridApi.grid.loadData([]); |
| | | await handleAddWorker(); |
| | | } |
| | | }, |
| | | }); |