| | |
| | | import type { MesProBatchApi } from "#/api/mes/pro/batch"; |
| | | import type { MesProPalletApi } from "#/api/mes/pro/pallet"; |
| | | |
| | | import { ref } from "vue"; |
| | | import { computed, ref } from "vue"; |
| | | |
| | | import { Barcode, useVbenModal } from "@vben/common-ui"; |
| | | import { DICT_TYPE } from "@vben/constants"; |
| | |
| | | |
| | | import { Button, Empty, Input, message, Spin, Tag } from "ant-design-vue"; |
| | | |
| | | import { getBagCodeByCode } from "#/api/mes/pro/bagCode"; |
| | | import { getBagCodeByCode, resolveTraceType } from "#/api/mes/pro/bagCode"; |
| | | import { getBatchByCode } from "#/api/mes/pro/batch"; |
| | | import { getPalletByCode } from "#/api/mes/pro/pallet"; |
| | | import { getQrConfig } from "#/api/mes/trace/config"; |
| | | import { useDescription } from "#/components/description"; |
| | | |
| | | defineOptions({ name: "MesProQrTraceModal" }); |
| | |
| | | |
| | | const traceTypeLabel = ref(""); |
| | | |
| | | /** 识别二维码内容类型:批次号(含临时批次码)/ 托盘码(含临时托盘码)/ 袋码 */ |
| | | function resolveCodeType(raw: string): TraceType | undefined { |
| | | const value = raw.trim().toUpperCase(); |
| | | if (/^PB-\d{8}$/.test(value)) { |
| | | return "batch"; |
| | | /** 溯源 H5 页面基础地址(会话内只拉取一次) */ |
| | | const traceBaseUrl = ref<string>(""); |
| | | |
| | | async function ensureTraceBaseUrl() { |
| | | if (traceBaseUrl.value) return; |
| | | try { |
| | | const res = await getQrConfig(); |
| | | traceBaseUrl.value = res.traceBaseUrl || ""; |
| | | } catch { |
| | | traceBaseUrl.value = ""; |
| | | } |
| | | if (/^PL-\d{8}$/.test(value)) { |
| | | return "pallet"; |
| | | } |
| | | |
| | | /** 二维码展示内容:配置了溯源地址则拼 H5 URL,否则回退纯码值(与后端 buildQrCodeContent 一致) */ |
| | | const qrContent = computed(() => { |
| | | if (!currentCode.value) return ""; |
| | | const code = bagTrace.value?.code || currentCode.value; |
| | | return traceBaseUrl.value |
| | | ? `${traceBaseUrl.value}?code=${encodeURIComponent(code)}` |
| | | : code; |
| | | }); |
| | | |
| | | /** 从完整 H5 链接中提取追溯码(兼容扫码枪扫描打印标签链接) */ |
| | | function extractCodeFromUrl(raw: string): string { |
| | | const trimmed = raw.trim(); |
| | | if (/^https?:\/\//i.test(trimmed)) { |
| | | try { |
| | | const url = new URL(trimmed); |
| | | const code = url.searchParams.get("code"); |
| | | if (code) return code; |
| | | } catch { |
| | | // 解析失败按原始输入处理 |
| | | } |
| | | } |
| | | if (/^.*-P\d{2}$/.test(value)) { |
| | | return "pallet"; |
| | | } |
| | | if (/^BAG-\d{8}$/.test(value) || /^.*-\d{4}$/.test(value)) { |
| | | return "bag"; |
| | | } |
| | | return "batch"; |
| | | return trimmed; |
| | | } |
| | | |
| | | function resetState() { |
| | |
| | | |
| | | /** 扫码/输入二维码内容查询追溯信息 */ |
| | | async function handleSearch(raw?: string) { |
| | | const value = (raw ?? codeInput.value)?.trim().toUpperCase(); |
| | | const input = (raw ?? codeInput.value)?.trim() ?? ""; |
| | | const value = extractCodeFromUrl(input).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; |
| | |
| | | palletTrace.value = undefined; |
| | | batchTrace.value = undefined; |
| | | try { |
| | | if (type === "batch") { |
| | | batchTrace.value = await getBatchByCode(value); |
| | | traceTypeLabel.value = "批次号"; |
| | | // 编码已统一为「16 位纯数字」,无法再按前缀判断类型,改由后端按编码解析类型 |
| | | const type = await resolveTraceType(value); |
| | | if (type === "bag") { |
| | | bagTrace.value = await getBagCodeByCode(value); |
| | | currentCode.value = bagTrace.value.code || value; |
| | | traceTypeLabel.value = "袋码"; |
| | | } else if (type === "pallet") { |
| | | palletTrace.value = await getPalletByCode(value); |
| | | currentCode.value = palletTrace.value.code || palletTrace.value.tempCode || value; |
| | | traceTypeLabel.value = "托盘码"; |
| | | } else if (type === "batch") { |
| | | batchTrace.value = await getBatchByCode(value); |
| | | currentCode.value = batchTrace.value.code || batchTrace.value.tempCode || value; |
| | | traceTypeLabel.value = "批次号"; |
| | | } else { |
| | | bagTrace.value = await getBagCodeByCode(value); |
| | | traceTypeLabel.value = "袋码"; |
| | | searchError.value = true; |
| | | currentCode.value = undefined; |
| | | return; |
| | | } |
| | | traceType.value = type; |
| | | } catch { |
| | |
| | | } |
| | | } |
| | | |
| | | /** 当前追溯数据的关键信息(下载/打印图片叠加文字用) */ |
| | | 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 || d.tempCode || "" }, |
| | | { 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 || d.tempCode || "" }, |
| | | { 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(">", ">"); |
| | | } |
| | | |
| | | /** 下载二维码 PNG(二维码 + 关键信息文字合成) */ |
| | | /** 下载二维码 PNG(二维码在上,码值在下) */ |
| | | function handleDownload() { |
| | | const base64 = barcodeRef.value?.getImageBase64(); |
| | | if (!base64) { |
| | | message.warning("二维码生成失败,无法下载"); |
| | | return; |
| | | } |
| | | const items = buildLabelText(); |
| | | const code = currentCode.value || ""; |
| | | const canvas = document.createElement("canvas"); |
| | | const ctx = canvas.getContext("2d"); |
| | | if (!ctx) { |
| | |
| | | } |
| | | 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); |
| | | const padding = 16; |
| | | const gap = 4; |
| | | const textHeight = 28; |
| | | canvas.width = img.width + padding * 2; |
| | | canvas.height = img.height + padding * 2 + gap + textHeight; |
| | | 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; |
| | | } |
| | | }); |
| | | let fontSize = 20; |
| | | ctx.font = `bold ${fontSize}px Arial, sans-serif`; |
| | | while (ctx.measureText(code).width > img.width && fontSize > 1) { |
| | | ctx.font = `bold ${--fontSize}px Arial, sans-serif`; |
| | | } |
| | | ctx.textAlign = "center"; |
| | | ctx.textBaseline = "middle"; |
| | | ctx.fillText(code, canvas.width / 2, padding + img.height + gap + textHeight / 2); |
| | | const link = document.createElement("a"); |
| | | link.href = canvas.toDataURL("image/png"); |
| | | link.download = `${currentCode.value || "qr"}_${Date.now()}.png`; |
| | |
| | | message.warning("二维码生成失败,无法打印"); |
| | | return; |
| | | } |
| | | const items = buildLabelText(); |
| | | const code = currentCode.value || ""; |
| | | const printWindow = window.open("", "_blank"); |
| | | if (!printWindow) { |
| | | message.error("无法打开打印窗口,请检查浏览器设置"); |
| | |
| | | } |
| | | 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> |
| | | <style> |
| | | *{margin:0;padding:0;box-sizing:border-box} |
| | | body{font-family:Arial,sans-serif;background:#fff;color:#000} |
| | | .print-container{width:40mm;padding:2mm;break-inside:avoid;text-align:center} |
| | | .qr-img{display:block;width:36mm;height:36mm;object-fit:contain} |
| | | .code{display:block;width:36mm;height:5mm;margin-top:0.5mm} |
| | | @page{margin:0} |
| | | </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>`); |
| | | <svg class="code" viewBox="0 0 200 28" xmlns="http://www.w3.org/2000/svg" aria-label="码值"> |
| | | <text x="100" y="20" text-anchor="middle" font-family="Arial,sans-serif" font-size="20" font-weight="700"${code.length > 16 ? ' textLength="196" lengthAdjust="spacingAndGlyphs"' : ''}>${escapeHtml(code)}</text> |
| | | </svg></div></body></html>`); |
| | | printWindow.document.close(); |
| | | printWindow.addEventListener("load", () => { |
| | | setTimeout(() => printWindow.print(), 500); |
| | |
| | | if (!isOpen) { |
| | | return; |
| | | } |
| | | ensureTraceBaseUrl(); |
| | | const data = modalApi.getData<{ code?: string }>() || {}; |
| | | codeInput.value = data.code ?? undefined; |
| | | resetState(); |
| | |
| | | schema: [ |
| | | { |
| | | field: "code", |
| | | label: "袋码(二维码内容)", |
| | | label: "追溯码", |
| | | span: 2, |
| | | }, |
| | | { field: 'businessCode', label: '业务码(出入库)', span: 2 }, |
| | | { |
| | | field: "bagNo", |
| | | label: "袋序号", |
| | |
| | | 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" /> |
| | | <Barcode ref="barcodeRef" :content="qrContent" :width="200" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <p class="text-lg font-bold break-all">{{ currentCode }}</p> |