<script lang="ts" setup>
|
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, reactive, ref } from "vue";
|
|
import { useVbenModal } from "@vben/common-ui";
|
import { DICT_TYPE } from "@vben/constants";
|
import { getDictOptions } from "@vben/hooks";
|
|
import { Button, Input, message, Select } from "ant-design-vue";
|
|
import { useVbenForm } from "#/adapter/form";
|
import { TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
|
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,
|
} from "../data";
|
|
defineOptions({ name: "MesProBatchForm" });
|
|
const emit = defineEmits(["success"]);
|
|
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)
|
);
|
|
const getTitle = computed(() => {
|
switch (formType.value) {
|
case "detail": {
|
return $t("ui.actionTitle.view", ["生产批次"]);
|
}
|
case "update": {
|
return $t("ui.actionTitle.edit", ["生产批次"]);
|
}
|
case "fill": {
|
return "补录批次信息";
|
}
|
default: {
|
return $t("ui.actionTitle.create", ["生产批次"]);
|
}
|
}
|
});
|
|
const [Form, formApi] = useVbenForm({
|
commonConfig: {
|
componentProps: {
|
class: "w-full",
|
},
|
formItemClass: "col-span-1",
|
labelWidth: 110,
|
},
|
layout: "horizontal",
|
schema: [],
|
showDefaultActions: false,
|
wrapperClass: "grid-cols-3",
|
});
|
|
// ========== 生产人员配置子表 ==========
|
const [WorkerGrid, workerGridApi] = useVbenVxeGrid({
|
gridOptions: {
|
autoResize: true,
|
border: true,
|
columns: useWorkerEditGridColumns(),
|
minHeight: 160,
|
pagerConfig: { enabled: false },
|
rowConfig: { isHover: true, keyField: "_rowKey" },
|
showOverflow: true,
|
toolbarConfig: { enabled: false },
|
} as VxeTableGridOptions<MesProBatchApi.BatchWorker>,
|
});
|
|
/** 新增行的临时行号(负数自增,避免与 loadData 的 index 冲突) */
|
let workerRowSeq = 0;
|
|
/** 添加人员行 */
|
async function handleAddWorker() {
|
await waitWorkerGridReady();
|
await workerGridApi.grid.insertAt({ _rowKey: -(++workerRowSeq) }, -1);
|
}
|
|
/** 删除人员行 */
|
function handleDeleteWorker(row: MesProBatchApi.BatchWorker) {
|
workerGridApi.grid.remove(row);
|
}
|
|
/** 人员选择编号变化(单选场景下取第一个值) */
|
function handleWorkerUserIdChange(
|
value: number | number[] | undefined,
|
row: MesProBatchApi.BatchWorker
|
) {
|
row.userId = Array.isArray(value) ? value[0] : value;
|
}
|
|
/** 人员选择变化时记录姓名(便于校验提示) */
|
function handleWorkerUserChange(
|
user: SystemUserApi.User | SystemUserApi.User[] | undefined,
|
row: MesProBatchApi.BatchWorker
|
) {
|
row.userName = Array.isArray(user) ? user[0]?.nickname : user?.nickname;
|
}
|
|
/** 岗位类型字典选项 */
|
const workerTypeOptions = computed(() =>
|
getDictOptions(DICT_TYPE.MES_PRO_BATCH_WORKER_TYPE, "number").map(
|
(item) => ({ label: item.label, value: item.value as number })
|
)
|
);
|
|
/** 校验并获取人员配置列表 */
|
async function getValidWorkers(): Promise<
|
MesProBatchApi.BatchWorker[] | undefined
|
> {
|
// getData() 只返回 loadData 加载的数据;insertAt 插入的行需通过
|
// getInsertRecords() 获取,并排除 getRemoveRecords() 中已删除的行
|
const data = workerGridApi.grid.getData() as Array<
|
MesProBatchApi.BatchWorker & { _rowKey?: number }
|
>;
|
const removeRecords = workerGridApi.grid.getRemoveRecords() as Array<
|
MesProBatchApi.BatchWorker & { _rowKey?: number }
|
>;
|
const insertRecords = workerGridApi.grid.getInsertRecords() as Array<
|
MesProBatchApi.BatchWorker & { _rowKey?: number }
|
>;
|
const rows = [
|
...data.filter(
|
(row) => !removeRecords.some((removed) => removed._rowKey === row._rowKey)
|
),
|
...insertRecords,
|
].map(({ _rowKey, ...rest }) => rest);
|
if (rows.length === 0) {
|
message.warning("请至少配置一名生产人员");
|
return undefined;
|
}
|
for (const [index, row] of rows.entries()) {
|
if (!row.userId) {
|
message.warning(`请选择第 ${index + 1} 行的生产人员`);
|
return undefined;
|
}
|
if (!row.workerType) {
|
message.warning(`请选择第 ${index + 1} 行的岗位类型`);
|
return undefined;
|
}
|
}
|
return rows;
|
}
|
|
/** 等待人员子表 grid 挂载完成(弹窗打开瞬间 onOpenChange 内部表格可能尚未挂载) */
|
async function waitWorkerGridReady(): Promise<void> {
|
await nextTick();
|
for (let i = 0; i < 100; i++) {
|
if (typeof workerGridApi.grid?.loadData === "function") {
|
return;
|
}
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
}
|
}
|
|
const [Modal, modalApi] = useVbenModal({
|
showCancelButton: false, // 自定义取消/关闭按钮,禁用默认取消按钮避免重复
|
async onConfirm() {
|
if (!isEditable.value) {
|
await modalApi.close();
|
return;
|
}
|
const { valid } = await formApi.validate();
|
if (!valid) return;
|
const workers = await getValidWorkers();
|
if (!workers) return;
|
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) {
|
await updateBatch({ ...data, id: formData.value.id, workers });
|
} else {
|
await createBatch({ ...data, workers });
|
}
|
message.success($t("ui.actionMessage.operationSuccess"));
|
emit("success");
|
await modalApi.close();
|
} finally {
|
modalApi.unlock();
|
}
|
},
|
async onOpenChange(isOpen: boolean) {
|
if (!isOpen) {
|
formData.value = undefined;
|
return;
|
}
|
const data = modalApi.getData<{ formType: FormType; id?: number }>();
|
formType.value = data.formType;
|
// 每次打开重置绑定候选,下拉改为滚动分页加载,首批在表单挂载后异步拉取
|
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
|
? useWorkerEditGridColumns()
|
: useWorkerReadGridColumns(),
|
});
|
if (data?.id) {
|
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 })
|
);
|
await waitWorkerGridReady();
|
await workerGridApi.grid.loadData(workers);
|
} finally {
|
modalApi.unlock();
|
}
|
} else {
|
await waitWorkerGridReady();
|
await workerGridApi.grid.loadData([]);
|
await handleAddWorker();
|
}
|
},
|
});
|
</script>
|
|
<template>
|
<Modal :title="getTitle"
|
class="w-3/5"
|
content-class="!max-h-[80vh] overflow-y-auto">
|
<Form class="mx-4" />
|
<div class="mx-4 mb-4">
|
<div class="mb-2 flex items-center justify-between">
|
<span class="font-medium">生产人员配置</span>
|
<Button v-if="isEditable" size="small" type="primary" @click="handleAddWorker">
|
添加人员
|
</Button>
|
</div>
|
<WorkerGrid>
|
<template #userId="{ row }">
|
<UserSelect
|
:model-value="row.userId"
|
placeholder="请选择生产人员"
|
@update:model-value="handleWorkerUserIdChange($event, row)"
|
@change="handleWorkerUserChange($event, row)"
|
/>
|
</template>
|
<template #workerType="{ row }">
|
<Select
|
v-model:value="row.workerType"
|
:options="workerTypeOptions"
|
class="w-full"
|
placeholder="请选择岗位类型"
|
/>
|
</template>
|
<template #remark="{ row }">
|
<Input v-model:value="row.remark" placeholder="请输入备注" />
|
</template>
|
<template #actions="{ row }">
|
<TableAction
|
:actions="[
|
{
|
label: $t('common.delete'),
|
type: 'link',
|
danger: true,
|
onClick: handleDeleteWorker.bind(null, row),
|
},
|
]"
|
/>
|
</template>
|
</WorkerGrid>
|
</div>
|
<template #append-footer>
|
<div class="flex gap-2">
|
<Button @click="modalApi.close()">
|
{{ isEditable ? '取消' : '关闭' }}
|
</Button>
|
</div>
|
</template>
|
</Modal>
|
</template>
|