<script lang="ts" setup>
|
import type { MesProBagCodeApi } from "#/api/mes/pro/bagCode";
|
import type { MesProBatchApi } from "#/api/mes/pro/batch";
|
import type { MesProPalletApi } from "#/api/mes/pro/pallet";
|
|
import { ref } from "vue";
|
|
import { Barcode, useVbenModal } from "@vben/common-ui";
|
import { DICT_TYPE } from "@vben/constants";
|
import { getDictLabel } from "@vben/hooks";
|
|
import { Button, Empty, Input, message, Spin, Tag } from "ant-design-vue";
|
|
import { getBagCodeByCode } from "#/api/mes/pro/bagCode";
|
import { getBatchByCode } from "#/api/mes/pro/batch";
|
import { getPalletByCode } from "#/api/mes/pro/pallet";
|
import { useDescription } from "#/components/description";
|
|
defineOptions({ name: "MesProQrTraceModal" });
|
|
type TraceType = "batch" | "pallet" | "bag";
|
|
const MAX_CODE_LENGTH = 64;
|
|
const codeInput = ref<string>();
|
const currentCode = ref<string>();
|
const traceType = ref<TraceType>();
|
const loading = ref(false);
|
const searched = ref(false);
|
const searchError = ref(false);
|
const bagTrace = ref<MesProBagCodeApi.BagCodeTrace>();
|
const palletTrace = ref<MesProPalletApi.PalletTrace>();
|
const batchTrace = ref<MesProBatchApi.BatchTrace>();
|
const barcodeRef = ref<InstanceType<typeof Barcode>>();
|
|
const traceTypeLabel = ref("");
|
|
/** 识别二维码内容类型:批次号 / 托盘码 / 袋码 */
|
function resolveCodeType(raw: string): TraceType | undefined {
|
const value = raw.trim().toUpperCase();
|
if (/^.*-P\d{2}$/.test(value)) {
|
return "pallet";
|
}
|
if (/^.*-\d{4}$/.test(value)) {
|
return "bag";
|
}
|
return "batch";
|
}
|
|
function resetState() {
|
currentCode.value = undefined;
|
traceType.value = undefined;
|
traceTypeLabel.value = "";
|
bagTrace.value = undefined;
|
palletTrace.value = undefined;
|
batchTrace.value = undefined;
|
searched.value = false;
|
searchError.value = false;
|
}
|
|
/** 扫码/输入二维码内容查询追溯信息 */
|
async function handleSearch(raw?: string) {
|
const value = (raw ?? codeInput.value)?.trim().toUpperCase();
|
if (!value) {
|
message.warning("请扫描或输入二维码内容");
|
return;
|
}
|
if (value.length > MAX_CODE_LENGTH) {
|
message.warning("二维码内容过长,请检查是否扫描正确");
|
return;
|
}
|
const type = resolveCodeType(value);
|
if (!type) {
|
message.warning("无法识别二维码类型");
|
return;
|
}
|
loading.value = true;
|
searched.value = true;
|
searchError.value = false;
|
currentCode.value = value;
|
traceType.value = undefined;
|
traceTypeLabel.value = "";
|
bagTrace.value = undefined;
|
palletTrace.value = undefined;
|
batchTrace.value = undefined;
|
try {
|
if (type === "batch") {
|
batchTrace.value = await getBatchByCode(value);
|
traceTypeLabel.value = "批次号";
|
} else if (type === "pallet") {
|
palletTrace.value = await getPalletByCode(value);
|
traceTypeLabel.value = "托盘码";
|
} else {
|
bagTrace.value = await getBagCodeByCode(value);
|
traceTypeLabel.value = "袋码";
|
}
|
traceType.value = type;
|
} catch {
|
searchError.value = true;
|
currentCode.value = undefined;
|
traceType.value = undefined;
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
/** 当前追溯数据的关键信息(下载/打印图片叠加文字用) */
|
function buildLabelText(): { label: string; value: string }[] {
|
if (traceType.value === "bag" && bagTrace.value) {
|
const d = bagTrace.value;
|
return [
|
{ label: "袋码", value: d.code || "" },
|
{ label: "袋序号", value: d.bagNo ? String(d.bagNo) : "" },
|
{ label: "批次号", value: d.batchCode || "" },
|
{ label: "品种", value: d.itemName || "" },
|
{ label: "规格", value: d.itemSpecification || "" },
|
{
|
label: "产线",
|
value: [d.lineCode, d.lineName].filter(Boolean).join(" / "),
|
},
|
{ label: "生产日期", value: d.produceDate || "" },
|
{ label: "责任人", value: d.ownerUserName || "" },
|
];
|
}
|
if (traceType.value === "pallet" && palletTrace.value) {
|
const d = palletTrace.value;
|
return [
|
{ label: "托盘码", value: d.code || "" },
|
{ label: "托盘序号", value: d.palletNo ? String(d.palletNo) : "" },
|
{ label: "批次号", value: d.batchCode || "" },
|
{ label: "品种", value: d.itemName || "" },
|
{ label: "规格", value: d.itemSpecification || "" },
|
{
|
label: "产线",
|
value: [d.lineCode, d.lineName].filter(Boolean).join(" / "),
|
},
|
{ label: "生产日期", value: d.produceDate || "" },
|
{ label: "责任人", value: d.ownerUserName || "" },
|
];
|
}
|
if (batchTrace.value) {
|
const d = batchTrace.value;
|
return [
|
{ label: "批次号", value: d.code || "" },
|
{ label: "品种", value: d.itemName || "" },
|
{ label: "规格", value: d.itemSpecification || "" },
|
{
|
label: "产线",
|
value: [d.lineCode, d.lineName].filter(Boolean).join(" / "),
|
},
|
{ label: "生产日期", value: d.produceDate || "" },
|
{ label: "计划数量", value: d.planQuantity ? String(d.planQuantity) : "" },
|
{ label: "责任人", value: d.ownerUserName || "" },
|
];
|
}
|
return [];
|
}
|
|
function escapeHtml(value: string) {
|
return value
|
.replaceAll("&", "&")
|
.replaceAll("<", "<")
|
.replaceAll(">", ">");
|
}
|
|
/** 下载二维码 PNG(二维码 + 关键信息文字合成) */
|
function handleDownload() {
|
const base64 = barcodeRef.value?.getImageBase64();
|
if (!base64) {
|
message.warning("二维码生成失败,无法下载");
|
return;
|
}
|
const items = buildLabelText();
|
const canvas = document.createElement("canvas");
|
const ctx = canvas.getContext("2d");
|
if (!ctx) {
|
message.error("Canvas 初始化失败");
|
return;
|
}
|
const img = new Image();
|
img.onload = () => {
|
const padding = 20;
|
const infoX = img.width + padding * 2;
|
const infoWidth = 320;
|
canvas.width = infoX + infoWidth + padding;
|
canvas.height = Math.max(img.height + padding * 2, 300);
|
ctx.fillStyle = "#ffffff";
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, padding, padding);
|
ctx.fillStyle = "#000000";
|
let y = padding + 10;
|
const lineHeight = 26;
|
items.forEach((item) => {
|
ctx.font = "bold 14px 'Microsoft YaHei', Arial, sans-serif";
|
ctx.fillText(`${item.label}:`, infoX, y);
|
y += 18;
|
ctx.font = "14px 'Microsoft YaHei', Arial, sans-serif";
|
const text = item.value || "-";
|
const maxWidth = infoWidth - 10;
|
if (ctx.measureText(text).width > maxWidth) {
|
let currentText = text;
|
while (currentText.length > 0) {
|
let line = "";
|
for (let i = 0; i < currentText.length; i++) {
|
const testLine = line + currentText[i];
|
if (ctx.measureText(testLine).width > maxWidth) {
|
break;
|
}
|
line = testLine;
|
}
|
ctx.fillText(line, infoX + 5, y);
|
y += 16;
|
currentText = currentText.slice(line.length);
|
}
|
y += 6;
|
} else {
|
ctx.fillText(text, infoX + 5, y);
|
y += lineHeight;
|
}
|
});
|
const link = document.createElement("a");
|
link.href = canvas.toDataURL("image/png");
|
link.download = `${currentCode.value || "qr"}_${Date.now()}.png`;
|
link.click();
|
message.success("下载成功");
|
};
|
img.src = base64;
|
}
|
|
/** 打印二维码 */
|
function handlePrint() {
|
const base64 = barcodeRef.value?.getImageBase64();
|
if (!base64) {
|
message.warning("二维码生成失败,无法打印");
|
return;
|
}
|
const items = buildLabelText();
|
const printWindow = window.open("", "_blank");
|
if (!printWindow) {
|
message.error("无法打开打印窗口,请检查浏览器设置");
|
return;
|
}
|
printWindow.document.write(`<!DOCTYPE html>
|
<html><head><meta charset="UTF-8"><title>打印二维码</title>
|
<style>*{margin:0;padding:0}body{font-family:'Microsoft YaHei',Arial,sans-serif;padding:20px}.print-container{text-align:center}.qr-img{max-width:100%;margin:20px 0}.info{margin-top:20px;text-align:left;font-size:13px}.info p{margin:4px 0}@media print{body{padding:0}.print-container{padding:20px}}</style>
|
</head><body><div class="print-container">
|
<img src="${base64}" class="qr-img" alt="二维码" />
|
<div class="info">
|
${items.map((item) => `<p><strong>${escapeHtml(item.label)}:</strong> ${escapeHtml(item.value || "-")}</p>`).join("")}
|
</div></div></body></html>`);
|
printWindow.document.close();
|
printWindow.addEventListener("load", () => {
|
setTimeout(() => printWindow.print(), 500);
|
});
|
}
|
|
const [Modal, modalApi] = useVbenModal({
|
onOpenChange(isOpen: boolean) {
|
if (!isOpen) {
|
return;
|
}
|
const data = modalApi.getData<{ code?: string }>() || {};
|
codeInput.value = data.code ?? undefined;
|
resetState();
|
if (data.code) {
|
handleSearch(data.code);
|
}
|
},
|
});
|
|
const [BagDescriptions] = useDescription({
|
bordered: true,
|
column: 2,
|
schema: [
|
{
|
field: "code",
|
label: "袋码(二维码内容)",
|
span: 2,
|
},
|
{
|
field: "bagNo",
|
label: "袋序号",
|
},
|
{
|
field: "status",
|
label: "袋码状态",
|
render: (val) =>
|
val ? getDictLabel(DICT_TYPE.MES_PRO_BAG_CODE_STATUS, val) : "",
|
},
|
{
|
field: "batchCode",
|
label: "生产批次号",
|
},
|
{
|
field: "batchStatus",
|
label: "批次状态",
|
render: (val) =>
|
val ? getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, val) : "",
|
},
|
{
|
field: "itemName",
|
label: "品种",
|
},
|
{
|
field: "itemSpecification",
|
label: "规格",
|
},
|
{
|
field: "produceDate",
|
label: "生产日期",
|
},
|
{
|
field: "ownerUserName",
|
label: "责任人",
|
},
|
{
|
field: "lineCode",
|
label: "产线",
|
render: (val, data) =>
|
[val, data?.lineName].filter(Boolean).join(" / "),
|
},
|
{
|
field: "palletCode",
|
label: "绑定托盘码",
|
render: (val) => val || "未绑定",
|
},
|
{
|
field: "printTime",
|
label: "导出印刷时间",
|
},
|
],
|
});
|
|
const [PalletDescriptions] = useDescription({
|
bordered: true,
|
column: 2,
|
schema: [
|
{
|
field: "code",
|
label: "托盘码(二维码内容)",
|
span: 2,
|
},
|
{
|
field: "palletNo",
|
label: "托盘序号",
|
},
|
{
|
field: "capacity",
|
label: "可装袋数(袋/托盘)",
|
},
|
{
|
field: "bagCount",
|
label: "已装袋数",
|
},
|
{
|
field: "status",
|
label: "托盘状态",
|
render: (val) =>
|
val ? getDictLabel(DICT_TYPE.MES_PRO_PALLET_STATUS, val) : "",
|
},
|
{
|
field: "batchCode",
|
label: "生产批次号",
|
},
|
{
|
field: "batchStatus",
|
label: "批次状态",
|
render: (val) =>
|
val ? getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, val) : "",
|
},
|
{
|
field: "itemName",
|
label: "品种",
|
},
|
{
|
field: "itemSpecification",
|
label: "规格",
|
},
|
{
|
field: "produceDate",
|
label: "生产日期",
|
},
|
{
|
field: "ownerUserName",
|
label: "责任人",
|
},
|
{
|
field: "lineCode",
|
label: "产线",
|
render: (val, data) =>
|
[val, data?.lineName].filter(Boolean).join(" / "),
|
},
|
],
|
});
|
|
const [BatchDescriptions] = useDescription({
|
bordered: true,
|
column: 2,
|
schema: [
|
{
|
field: "code",
|
label: "批次号(二维码内容)",
|
span: 2,
|
},
|
{
|
field: "status",
|
label: "批次状态",
|
render: (val) =>
|
val ? getDictLabel(DICT_TYPE.MES_PRO_BATCH_STATUS, val) : "",
|
},
|
{
|
field: "itemName",
|
label: "品种",
|
},
|
{
|
field: "itemSpecification",
|
label: "规格",
|
},
|
{
|
field: "lineCode",
|
label: "产线",
|
render: (val, data) =>
|
[val, data?.lineName].filter(Boolean).join(" / "),
|
},
|
{
|
field: "produceDate",
|
label: "生产日期",
|
},
|
{
|
field: "planQuantity",
|
label: "计划数量",
|
},
|
{
|
field: "bagQuantity",
|
label: "已生成袋码数量",
|
},
|
{
|
field: "palletQuantity",
|
label: "已生成托盘码数量",
|
},
|
{
|
field: "ownerUserName",
|
label: "责任人",
|
},
|
{
|
field: "remark",
|
label: "备注",
|
},
|
],
|
});
|
</script>
|
|
<template>
|
<Modal
|
:show-confirm-button="false"
|
cancel-text="关闭"
|
class="w-3/5"
|
title="扫码追溯 / 查看二维码"
|
>
|
<div class="mx-4">
|
<div class="mb-4 flex gap-2">
|
<Input
|
v-model:value="codeInput"
|
allow-clear
|
class="!w-96"
|
placeholder="请扫描或输入批次号 / 托盘码 / 袋码,回车查询"
|
@press-enter="() => handleSearch()"
|
/>
|
<Button :loading="loading" type="primary" @click="() => handleSearch()">
|
查询
|
</Button>
|
</div>
|
<Spin :spinning="loading">
|
<template v-if="currentCode && traceType">
|
<div
|
class="mb-4 flex items-center gap-6 rounded bg-gray-100 p-5"
|
>
|
<div class="flex-shrink-0">
|
<Barcode ref="barcodeRef" :content="currentCode" :width="200" />
|
</div>
|
<div class="min-w-0 flex-1">
|
<p class="text-lg font-bold break-all">{{ currentCode }}</p>
|
<p class="mt-1 text-xs text-gray-500">
|
码类型:{{ traceTypeLabel }}
|
</p>
|
</div>
|
</div>
|
<template v-if="traceType === 'batch' && batchTrace">
|
<BatchDescriptions :data="batchTrace" class="mt-2" />
|
<div
|
v-if="batchTrace.workers?.length"
|
class="mt-3 rounded border border-solid border-gray-200 p-3"
|
>
|
<p class="mb-2 font-bold">
|
生产人员({{ batchTrace.workers.length }})
|
</p>
|
<div class="flex flex-wrap gap-2">
|
<Tag v-for="w in batchTrace.workers" :key="w.id">
|
{{ w.userName
|
}}<template v-if="w.workerType"
|
>({{
|
getDictLabel(DICT_TYPE.MES_PRO_BATCH_WORKER_TYPE, w.workerType)
|
}})</template
|
>
|
</Tag>
|
</div>
|
</div>
|
<div
|
v-if="batchTrace.pallets?.length"
|
class="mt-3 rounded border border-solid border-gray-200 p-3"
|
>
|
<p class="mb-2 font-bold">
|
托盘码({{ batchTrace.pallets.length }})
|
</p>
|
<div class="flex flex-wrap gap-2">
|
<Tag v-for="p in batchTrace.pallets" :key="p.id">
|
{{ p.code }}
|
</Tag>
|
</div>
|
</div>
|
<div
|
v-if="batchTrace.bags?.length"
|
class="mt-3 rounded border border-solid border-gray-200 p-3"
|
>
|
<p class="mb-2 font-bold">
|
袋码
|
<span
|
v-if="
|
batchTrace.bagTotal != null &&
|
batchTrace.bagTotal > batchTrace.bags.length
|
"
|
class="text-xs text-gray-400"
|
>
|
(仅展示前 {{ batchTrace.bags.length }} 条,共
|
{{ batchTrace.bagTotal }} 条)
|
</span>
|
</p>
|
<div class="flex max-h-48 flex-wrap gap-2 overflow-y-auto">
|
<Tag v-for="b in batchTrace.bags" :key="b.id">
|
{{ b.code }}
|
</Tag>
|
</div>
|
</div>
|
</template>
|
<BagDescriptions
|
v-else-if="traceType === 'bag' && bagTrace"
|
:data="bagTrace"
|
class="mt-2"
|
/>
|
<PalletDescriptions
|
v-else-if="traceType === 'pallet' && palletTrace"
|
:data="palletTrace"
|
class="mt-2"
|
/>
|
</template>
|
<Empty
|
v-else-if="searched"
|
:description="searchError ? '该码不存在或已删除' : '未查询到追溯信息'"
|
/>
|
</Spin>
|
</div>
|
<template v-if="currentCode && traceType" #append-footer>
|
<div class="flex gap-2">
|
<Button type="primary" @click="handlePrint">打印</Button>
|
<Button @click="handleDownload">下载二维码</Button>
|
</div>
|
</template>
|
</Modal>
|
</template>
|