<script lang="ts" setup>
|
import type { VbenFormSchema } from '#/adapter/form';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { MesWmProductIssueApi } from '#/api/mes/wm/productissue';
|
|
import { computed, h, markRaw, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
|
|
import {
|
DICT_TYPE,
|
MesAutoCodeRuleCode,
|
MesWmProductIssueStatusEnum,
|
} from '@vben/constants';
|
import { IconifyIcon } from '@vben/icons';
|
|
import { Button, Divider, Empty, message, Popconfirm, Tag } from 'ant-design-vue';
|
|
import { useVbenForm } from '#/adapter/form';
|
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { generateAutoCode } from '#/api/mes/md/autocode/record';
|
import {
|
cancelProductIssue,
|
createProductIssue,
|
deleteProductIssue,
|
getProductIssue,
|
getProductIssuePage,
|
submitProductIssue,
|
updateProductIssue,
|
} from '#/api/mes/wm/productissue';
|
import { getProductIssueLinesByIssue } from '#/api/mes/wm/productissue/line';
|
import dayjs from 'dayjs';
|
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
|
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
|
|
import ProductIssueFormModal from './ProductIssueFormModal.vue';
|
|
defineOptions({ name: 'WorkbenchProductIssueTab' });
|
|
const props = defineProps<{
|
/** 当前工作站 ID */
|
workstationId?: number;
|
/** 当前工作站名称 */
|
workstationName?: string;
|
/** 当前选中的任务 ID */
|
taskId?: number;
|
/** 当前选中的任务编码 */
|
taskCode?: string;
|
/** 当前选中的任务名称 */
|
taskName?: string;
|
/** 当前选中的任务关联工单 ID */
|
workOrderId?: number;
|
}>();
|
|
// --- 表单状态 ---
|
const editingId = ref<number>();
|
const isCreating = ref(false);
|
|
/** 构建表单 schema */
|
function buildFormSchema(): VbenFormSchema[] {
|
return [
|
{
|
fieldName: 'code',
|
label: '领料单编号',
|
component: 'Input',
|
componentProps: { placeholder: '请输入领料单编号' },
|
rules: 'required',
|
suffix: () =>
|
h(
|
Button,
|
{
|
type: 'default',
|
size: 'small',
|
onClick: async () => {
|
const code = await generateAutoCode(
|
MesAutoCodeRuleCode.WM_PRODUCT_ISSUE_CODE,
|
);
|
await formApi.setFieldValue('code', code);
|
},
|
},
|
{ default: () => '生成' },
|
),
|
},
|
{
|
fieldName: 'name',
|
label: '领料单名称',
|
component: 'Input',
|
componentProps: { placeholder: '请输入领料单名称' },
|
rules: 'required',
|
},
|
{
|
fieldName: 'taskId',
|
label: '生产任务',
|
component: 'Input',
|
componentProps: {
|
placeholder: '从选中任务自动带入',
|
disabled: true,
|
},
|
rules: 'required',
|
},
|
{
|
fieldName: 'workOrderId',
|
label: '生产订单',
|
component: markRaw(ProWorkOrderSelect),
|
componentProps: {
|
disabled: true,
|
},
|
},
|
{
|
fieldName: 'workstationId',
|
label: '工作站',
|
component: markRaw(MdWorkstationSelect),
|
componentProps: { placeholder: '从选中任务自动带入', disabled: true },
|
},
|
{
|
fieldName: 'requiredTime',
|
label: '需求时间',
|
component: 'DatePicker',
|
componentProps: {
|
class: '!w-full',
|
format: 'YYYY-MM-DD HH:mm:ss',
|
placeholder: '请选择需求时间',
|
showTime: true,
|
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
},
|
},
|
{
|
fieldName: 'remark',
|
label: '备注',
|
component: 'Textarea',
|
componentProps: { placeholder: '请输入备注', rows: 2 },
|
},
|
];
|
}
|
|
const [Form, formApi] = useVbenForm({
|
commonConfig: {
|
componentProps: { class: 'w-full' },
|
formItemClass: 'col-span-1',
|
labelWidth: 100,
|
},
|
layout: 'horizontal',
|
schema: buildFormSchema(),
|
showDefaultActions: false,
|
wrapperClass: 'grid-cols-3',
|
});
|
|
/** 新建领料单 */
|
async function handleCreate() {
|
isCreating.value = true;
|
editingId.value = undefined;
|
await formApi.resetForm();
|
// 预填任务信息
|
if (props.taskId) {
|
await formApi.setFieldValue('taskId', props.taskId);
|
}
|
if (props.workOrderId) {
|
await formApi.setFieldValue('workOrderId', props.workOrderId);
|
}
|
if (props.workstationId) {
|
await formApi.setFieldValue('workstationId', props.workstationId);
|
}
|
}
|
|
/** 保存领料单 */
|
async function handleSave() {
|
const { valid } = await formApi.validate();
|
if (!valid) return;
|
const data =
|
(await formApi.getValues()) as MesWmProductIssueApi.ProductIssue;
|
try {
|
if (editingId.value) {
|
await updateProductIssue({ ...data, id: editingId.value });
|
message.success('修改成功');
|
} else {
|
const id = await createProductIssue(data);
|
editingId.value = id;
|
await formApi.setFieldValue('id', id);
|
message.success('创建成功');
|
}
|
isCreating.value = false;
|
gridApi.query();
|
} catch {
|
message.error('保存失败');
|
}
|
}
|
|
/** 取消编辑 */
|
function handleCancelEdit() {
|
isCreating.value = false;
|
editingId.value = undefined;
|
formApi.resetForm();
|
}
|
|
/** 编辑领料单:打开编辑弹窗(仅草稿状态可编辑) */
|
function handleEditRow(row: MesWmProductIssueApi.ProductIssue) {
|
editFormModalApi.setData({ id: row.id }).open();
|
}
|
|
/** 提交 */
|
async function handleSubmitRow(row: MesWmProductIssueApi.ProductIssue) {
|
await submitProductIssue(row.id!);
|
message.success('提交成功');
|
gridApi.query();
|
}
|
|
/** 删除 */
|
async function handleDeleteRow(row: MesWmProductIssueApi.ProductIssue) {
|
await deleteProductIssue(row.id!);
|
message.success('删除成功');
|
gridApi.query();
|
}
|
|
/** 取消 */
|
async function handleCancelRow(row: MesWmProductIssueApi.ProductIssue) {
|
await cancelProductIssue(row.id!);
|
message.success('取消成功');
|
gridApi.query();
|
}
|
|
// --- 列表 ---
|
const gridColumns: VxeTableGridOptions<MesWmProductIssueApi.ProductIssue>['columns'] = [
|
{ field: 'code', title: '领料单编号', minWidth: 150 },
|
{ field: 'name', title: '领料单名称', minWidth: 130 },
|
{
|
field: 'taskCode',
|
title: '生产任务',
|
minWidth: 130,
|
formatter: ({ row }) => row.taskCode || props.taskCode || '',
|
},
|
{ field: 'workOrderCode', title: '生产订单', minWidth: 130 },
|
{
|
field: 'requiredTime',
|
title: '需求时间',
|
width: 170,
|
formatter: 'formatDateTime',
|
},
|
{
|
field: 'status',
|
title: '状态',
|
width: 100,
|
cellRender: {
|
name: 'CellDict',
|
props: { type: DICT_TYPE.MES_WM_PRODUCT_ISSUE_STATUS },
|
},
|
},
|
{ title: '操作', width: 360, fixed: 'right', slots: { default: 'actions' } },
|
];
|
|
const gridHeight = ref(300);
|
const listRef = ref<HTMLElement>();
|
let resizeObserver: ResizeObserver | null = null;
|
|
const [Grid, gridApi] = useVbenVxeGrid({
|
gridOptions: {
|
columns: gridColumns,
|
height: gridHeight.value,
|
keepSource: true,
|
proxyConfig: {
|
ajax: {
|
query: async ({ page }, formValues) => {
|
return await getProductIssuePage({
|
pageNo: page.currentPage,
|
pageSize: page.pageSize,
|
taskId: props.taskId,
|
...formValues,
|
});
|
},
|
},
|
},
|
rowConfig: { keyField: 'id', isHover: true },
|
toolbarConfig: { refresh: true },
|
pagerConfig: { pageSize: 10 },
|
} as VxeTableGridOptions<MesWmProductIssueApi.ProductIssue>,
|
});
|
|
function updateGridHeight() {
|
if (listRef.value) {
|
const h = listRef.value.clientHeight;
|
if (h > 100 && h !== gridHeight.value) {
|
gridHeight.value = h;
|
gridApi.setGridOptions({ height: h });
|
}
|
}
|
}
|
|
onMounted(() => {
|
nextTick(() => {
|
updateGridHeight();
|
if (listRef.value) {
|
resizeObserver = new ResizeObserver(() => updateGridHeight());
|
resizeObserver.observe(listRef.value);
|
}
|
});
|
});
|
|
onBeforeUnmount(() => {
|
resizeObserver?.disconnect();
|
resizeObserver = null;
|
});
|
|
// 任务或工作站变化时刷新列表
|
watch(
|
() => [props.taskId, props.workstationId],
|
() => gridApi.query(),
|
);
|
|
/** 是否已选中工作站和任务,未选中时不展示表格 */
|
const isReady = computed(() => !!props.taskId && !!props.workstationId);
|
|
/** 单据内容全部按文本转义,避免物料名称或备注影响打印页面结构。 */
|
function escapePrintText(value: unknown) {
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
})[char] || char);
|
}
|
|
async function handlePrintIssue(row: MesWmProductIssueApi.ProductIssue) {
|
if (!row.id) return;
|
// 先打开窗口,避免异步请求结束后被浏览器作为弹窗拦截。
|
const popup = window.open('', '_blank');
|
if (!popup) { message.warning('浏览器拦截了打印窗口,请允许弹窗后重试'); return; }
|
popup.document.title = '正在准备领料单';
|
popup.document.body.textContent = '正在准备领料单…';
|
try {
|
const [issue, lines] = await Promise.all([getProductIssue(row.id), getProductIssueLinesByIssue(row.id)]);
|
if (!issue) throw new Error('领料单不存在');
|
const safe = escapePrintText;
|
const date = (value: unknown) => value && dayjs(value as string | number | Date).isValid()
|
? dayjs(value as string | number | Date).format('YYYY-MM-DD HH:mm') : '';
|
const statusNames: Record<number, string> = {
|
[MesWmProductIssueStatusEnum.PREPARE]: '草稿',
|
[MesWmProductIssueStatusEnum.APPROVING]: '待拣货',
|
[MesWmProductIssueStatusEnum.APPROVED]: '待领出',
|
[MesWmProductIssueStatusEnum.FINISHED]: '已完成',
|
[MesWmProductIssueStatusEnum.CANCELED]: '已取消',
|
};
|
const rows = lines.map((item, index) => `<tr><td>${index + 1}</td><td>${safe(item.itemCode)}</td><td>${safe(item.itemName)}</td><td>${safe(item.specification)}</td><td>${safe(item.unitMeasureName)}</td><td>${safe(item.quantity)}</td><td>${safe(item.remark)}</td></tr>`).join('');
|
popup.document.open();
|
popup.document.write(`<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>领料单 ${safe(issue.code)}</title><style>
|
@page { size: A4; margin: 15mm; } body { color: #111; font: 13px 'Microsoft YaHei', sans-serif; margin: 0 auto; max-width: 180mm; }
|
h1 { text-align: center; font-size: 22px; margin: 12px 0 20px; } .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 16px; margin-bottom: 16px; overflow-wrap: anywhere; }
|
table { border-collapse: collapse; width: 100%; table-layout: fixed; } th,td { border: 1px solid #555; padding: 7px 4px; word-break: break-word; text-align: left; } th:first-child,td:first-child { width: 32px; text-align: center; } th:nth-child(5),td:nth-child(5) { width: 42px; } th:nth-child(6),td:nth-child(6) { width: 56px; } thead { display: table-header-group; } tr { break-inside: avoid; }
|
.sign { display: flex; justify-content: space-between; gap: 18px; margin-top: 32px; } .toolbar { text-align: right; padding: 12px 0; } button { padding: 7px 14px; cursor: pointer; } @media print { .toolbar { display: none; } }
|
</style></head><body><div class="toolbar"><button onclick="window.print()">打印 / 保存为 PDF</button></div><h1>领料单</h1>
|
<div class="meta"><div>领料单号:${safe(issue.code)}</div><div>领料单名称:${safe(issue.name)}</div><div>生产任务:${safe(issue.taskCode)}</div><div>生产订单:${safe(issue.workOrderCode)}</div><div>工作站:${safe(issue.workstationName || issue.workstationCode)}</div><div>需求时间:${safe(date(issue.requiredTime))}</div><div>状态:${safe(statusNames[issue.status ?? -1] ?? '未知')}</div><div>备注:${safe(issue.remark)}</div></div>
|
<table><thead><tr><th>序号</th><th>物料编码</th><th>物料名称</th><th>规格型号</th><th>单位</th><th>申请数量</th><th>备注</th></tr></thead><tbody>${rows || '<tr><td colspan="7">暂无领料明细</td></tr>'}</tbody></table>
|
<div class="sign"><span>申请人:____________</span><span>发料人:____________</span><span>领料人:____________</span><span>日期:____________</span></div></body></html>`);
|
popup.document.close();
|
} catch (error) {
|
popup.close();
|
message.error('无法生成领料单,请稍后重试');
|
console.error(error);
|
}
|
}
|
|
/** 编辑弹窗 */
|
const [EditFormModal, editFormModalApi] = useVbenModal({
|
connectedComponent: ProductIssueFormModal,
|
destroyOnClose: true,
|
});
|
|
/** 编辑弹窗操作成功后刷新列表 */
|
function onEditSuccess() {
|
gridApi.query();
|
}
|
</script>
|
|
<template>
|
<div class="issue-tab">
|
<!-- 未选中工作站或任务时显示提示 -->
|
<div v-if="!isReady" class="issue-tab__empty">
|
<Empty
|
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
description="请先在左侧选择工作站和任务"
|
/>
|
</div>
|
<template v-else>
|
<!-- 顶部:表单 / 新建按钮 -->
|
<div class="issue-tab__header">
|
<div class="issue-tab__header-left">
|
<IconifyIcon icon="lucide:clipboard-list" class="issue-tab__icon" />
|
<span class="issue-tab__title">领料单</span>
|
<Tag v-if="isCreating" color="blue">新建中</Tag>
|
</div>
|
<div class="issue-tab__header-actions">
|
<template v-if="!isCreating">
|
<Button type="primary" size="small" @click="handleCreate">
|
<template #icon><IconifyIcon icon="lucide:plus" /></template>
|
新建领料单
|
</Button>
|
</template>
|
<template v-else>
|
<Button type="primary" size="small" @click="handleSave">
|
<template #icon><IconifyIcon icon="lucide:save" /></template>
|
保存
|
</Button>
|
<Button size="small" @click="handleCancelEdit">取消</Button>
|
</template>
|
</div>
|
</div>
|
|
<!-- 表单区域(仅新建时展示) -->
|
<div v-if="isCreating" class="issue-tab__form">
|
<Form />
|
</div>
|
|
<Divider style="margin: 12px 0" />
|
|
<!-- 底部:领料记录列表 -->
|
<div ref="listRef" class="issue-tab__list">
|
<Grid table-title="领料记录">
|
<template #actions="{ row }">
|
<Button type="link" size="small" @click="handlePrintIssue(row)">打印 / 导出 PDF</Button>
|
<Button
|
v-if="row.status === MesWmProductIssueStatusEnum.PREPARE"
|
type="link"
|
size="small"
|
@click="handleEditRow(row)"
|
>
|
编辑
|
</Button>
|
<Popconfirm
|
v-if="row.status === MesWmProductIssueStatusEnum.PREPARE"
|
title="确认提交?提交后不能修改"
|
@confirm="handleSubmitRow(row)"
|
>
|
<Button type="link" size="small">提交</Button>
|
</Popconfirm>
|
<Popconfirm
|
v-if="row.status === MesWmProductIssueStatusEnum.PREPARE"
|
:title="`确认删除 ${row.code}?`"
|
@confirm="handleDeleteRow(row)"
|
>
|
<Button type="link" size="small" danger>删除</Button>
|
</Popconfirm>
|
<Popconfirm
|
v-if="
|
row.status === MesWmProductIssueStatusEnum.APPROVING ||
|
row.status === MesWmProductIssueStatusEnum.APPROVED
|
"
|
title="确认取消?取消后不可恢复"
|
@confirm="handleCancelRow(row)"
|
>
|
<Button type="link" size="small" danger>取消</Button>
|
</Popconfirm>
|
</template>
|
</Grid>
|
</div>
|
|
</template>
|
|
<!-- 编辑弹窗 -->
|
<EditFormModal @success="onEditSuccess" />
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.issue-tab {
|
padding: 20px 24px;
|
height: 100%;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
}
|
|
.issue-tab__header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
}
|
|
.issue-tab__header-left {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.issue-tab__icon {
|
font-size: 22px;
|
color: #1890ff;
|
}
|
|
.issue-tab__title {
|
font-size: 17px;
|
font-weight: 600;
|
color: #1a1a1a;
|
}
|
|
.issue-tab__header-actions {
|
display: flex;
|
gap: 8px;
|
}
|
|
.issue-tab__form {
|
margin-top: 16px;
|
padding: 16px;
|
background: #fafbfc;
|
border-radius: 10px;
|
border: 1px solid #f0f0f0;
|
}
|
|
.issue-tab__list {
|
flex: 1;
|
min-height: 0;
|
overflow: hidden;
|
}
|
|
.issue-tab__empty {
|
flex: 1;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
</style>
|