
🧑 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
vue3项目实战 打印、导出PDF
一 维护模板
1 打印模板:
<template> <div class="print-content"> <div v-for="item in data.detailList" :key="item.id" class="label-item"> <!-- 顶部价格区域 - 最醒目 --> <div class="price-header"> <div class="main-price"> <span class="price-value">{{ formatPrice(item.detailPrice) }}</span> <span class="currency">¥</span> </div> <div v-if="item.originalPrice && item.originalPrice !== item.detailPrice" class="origin-price"> 原价 ¥{{ formatPrice(item.originalPrice) }} </div> </div> <!-- 商品信息区域 --> <div class="product-info"> <div class="product-name">{{ truncateText(item.skuName, 20) }}</div> <div class="product-code">{{ item.skuCode || item.skuName.slice(-8) }}</div> </div> <!-- 条码区域 --> <div class="barcode-section" v-if="item.showBarcode !== false"> <img :src="item.skuCodeImg || '123456789'" alt="条码" class="barcode" v-if="item.skuCode"> </div> <!-- 底部信息区域 --> <div class="footer-info"> <div class="info-row"> <span class="location">{{ item.location || "A1-02" }}</span> <span class="stock">库存{{ item.stock || 36 }}</span> </div> </div> </div> </div> </template> <script> export default { props: { data: { type: Object, required: true } }, methods: { formatPrice(price) { return parseFloat(price || 0).toFixed(2); }, truncateText(text, maxLength) { if (!text) return ''; return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; } } } </script> <style scoped lang="scss"> /* 主容器 - 网格布局 */ .print-content { display: grid; /* 启用 CSS Grid 布局 */ grid-template-columns: repeat(auto-fill, 50mm); /* 每列宽 50mm,自动填充剩余空间 */ grid-auto-rows: 30mm; /* 每行固定高度 30mm */ background: #f5f5f5; /* 网格背景色(浅灰色) */ /* 单个标签样式 */ .label-item { width: 50mm; height: 30mm; background: #ffffff; border-radius: 2mm; display: flex; flex-direction: column; position: relative; overflow: hidden; page-break-inside: avoid; font-family: 'OCR','ShareTechMono', 'Condensed','Liberation Mono','Microsoft YaHei', 'SimSun', 'Arial', monospace; box-shadow: none; /* 避免阴影被打印 */ /* 价格头部区域 - 最醒目 */ .price-header { background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%); color: white; padding: 1mm 2mm; text-align: center; position: relative; .main-price { display: flex; align-items: baseline; justify-content: center; line-height: 1; .currency { color: #000 !important; font-weight: bold; margin-left: 2mm; } .price-value { font-size: 16px; font-weight: 900; letter-spacing: -0.5px; color: #000 !important; } } .origin-price { font-size: 6px; opacity: 0.8; text-decoration: line-through; margin-top: 0.5mm; } /* 特殊效果 - 价格角标 */ &::after { content: ''; position: absolute; bottom: -1mm; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 2mm solid transparent; border-right: 2mm solid transparent; border-top: 1mm solid #1976D2; } } /* 商品信息区域 */ .product-info { padding: 1.5mm 2mm 1mm 2mm; flex: 1; display: flex; flex-direction: column; justify-content: center; .product-name { font-size: 10px; font-weight: 600; color: #000 !important; line-height: 1.2; text-align: center; margin-bottom: 0.5mm; overflow: hidden; display: -webkit-box; --webkit-line-clamp: 2; -webkit-box-orient: vertical; } .product-code { font-size: 8px; color: #000 !important; text-align: center; font-family: 'Courier New', monospace; letter-spacing: 0.3px; } } /* 条码区域 */ .barcode-section { padding: 0 1mm; text-align: center; height: 6mm; display: flex; align-items: center; justify-content: center; .barcode { height: 5mm; max-width: 46mm; object-fit: contain; } } /* 底部信息区域 */ .footer-info { background: #f8f9fa; padding: 0.8mm 2mm; border-top: 0.5px solid #e0e0e0; .info-row { display: flex; justify-content: space-between; align-items: center; .location, .stock { font-size: 5px; color: #666; font-weight: 500; } .location { background: #e3f2fd; color: #1976d2; padding: 0.5mm 1mm; border-radius: 1mm; font-weight: 600; } .stock { background: #f3e5f5; color: #7b1fa2; padding: 0.5mm 1mm; border-radius: 1mm; font-weight: 600; } } } } } /* 打印优化 */ @media print { .price-header { /* 打印时使用模板颜色 */ -webkit-print-color-adjust: exact; print-color-adjust: exact; } } </style>
2 注意说明:
1 注意:使用原生的标签 + vue3响应式 ,不可以使用element-plus; 2 @media print{} 用来维护打印样式,最好在打印封装中统一维护,否则交叉样式会被覆盖;
二 封装获取模板
1 模板设计
// 1 模板类型: -- invoice-A4发票 ticket-80mm热敏小票 label-货架标签 // 2 模板写死在前端,通过更新前端维护 -- src/compoments/print/template/invoice/... -- src/compoments/print/template/ticket/... -- src/compoments/print/template/label/... // 3 通过 模板类型 templateType 、模板路径 templatePath -> 获取唯一模板 -- 前端实现模板获取
2 封装模板获取
// src/utils/print/templateLoader.js import { TEMPLATE_MAP } from '@/components/Print/templates'; const templateCache = new Map(); const MAX_CACHE_SIZE = 10; // 防止内存无限增长 export async function loadTemplate(type, path, isFallback = false) { console.log('loadTemplate 进行模板加载:', type, path, isFallback); const cacheKey = `${type}/${path}`; // 检查缓存 if (templateCache.has(cacheKey)) { return templateCache.get(cacheKey); } try { // 检查类型和路径是否有效 if (!TEMPLATE_MAP[type] || !TEMPLATE_MAP[type][path]) { throw new Error(`模板 ${type}/${path} 未注册`); } // 动态加载模块 const module = await TEMPLATE_MAP[type][path](); // 清理最久未使用的缓存 if (templateCache.size >= MAX_CACHE_SIZE) { // Map 的 keys() 是按插入顺序的迭代器 const oldestKey = templateCache.keys().next().value; templateCache.delete(oldestKey); } templateCache.set(cacheKey, module.default); return module.default; } catch (e) { console.error(`加载模板失败: ${type}/${path}`, e); // 回退到默认模板 if (isFallback || path === 'Default') { throw new Error(`无法加载模板 ${type}/${path} 且默认模板也不可用`); } return loadTemplate(type, 'Default', true); } }
三 生成打印数据
1 根据模板 + 打印数据 -> 生成 html(支持二维码、条形码)
import JsBarcode from 'jsbarcode'; import { createApp, h } from 'vue'; import { isExternal } from "@/utils/validate"; import QRCode from 'qrcode'; // 1 生成条码图片 function generateBarcodeBase64(code) { if (!code) return ''; const canvas = document.createElement('canvas'); try { JsBarcode(canvas, code, { format: 'CODE128', // 条码格式 CODE128、EAN13、EAN8、UPC、CODE39、ITF、MSI... displayValue: false, // 是否显示条码值 width: 2, // 条码宽度 height: 40, // 条码高度 margin: 0, // 条码外边距 }); return canvas.toDataURL('image/png'); } catch (err) { console.warn('条码生成失败:', err); return ''; } } // 2 拼接图片路径 function getImageUrl(imgSrc) { if (!imgSrc) { return '' } try { const src = imgSrc.split(",")[0].trim(); // 2.1 判断图片路径是否为完整路径 return isExternal(src) ? src : `${import.meta.env.VITE_APP_BASE_API}${src}`; } catch (err) { console.warn('图片路径拼接失败:', err); return ''; } } // 更安全的QR码生成 async function generateQRCode(url) { if (!url) return ''; try { return await QRCode.toDataURL(url.toString()) } catch (err) { console.warn('QR码生成失败:', err); return ''; } } /** * 3 打印模板渲染数据 * @param {*} Component 模板组件 * @param {*} printData 打印数据 * @returns html */ export default async function renderTemplate(Component, printData) { // 1. 数据验证和初始化 if (!printData || typeof printData !== 'object') { throw new Error('Invalid data format'); } // 2. 创建安全的数据副本 const data = { ...printData, tenant: { ...printData.tenant, logo: printData?.tenant?.logo || '', logoImage: '' }, invoice: { ...printData.invoice, invoiceQr: printData?.invoice?.invoiceQr || '', invoiceQrImage: '' }, detailList: Array.isArray(printData.detailList) ? printData.detailList : [], invoiceDetailList: Array.isArray(printData.invoiceDetailList) ? printData.invoiceDetailList : [], }; // 3. 异步处理二维码和条码和logo try { // 3.1 处理二维码 if (data.invoice.invoiceQr) { data.invoice.invoiceQrImage = await generateQRCode(data.invoice.invoiceQr); } // 3.2 处理条码 if (data.detailList.length > 0) { data.detailList = data.detailList.map(item => ({ ...item, skuCodeImg: item.skuCode ? generateBarcodeBase64(item.skuCode) : '' })); } // 3.3 处理LOGO if (data.tenant.logo) { data.tenant.logoImage = getImageUrl(data.tenant?.logo); } } catch (err) { console.error('数据处理失败:', err); // 即使部分数据处理失败也继续执行 } // 4. 创建渲染容器 const div = document.createElement('div'); div.id = 'print-template-container'; // 5. 使用Promise确保渲染完成 return new Promise((resolve) => { const app = createApp({ render: () => h(Component, { data }) }); // 6. 特殊处理:等待两个tick确保渲染完成 app.mount(div); nextTick().then(() => { return nextTick(); // 双重确认 }).then(() => { const html = div.innerHTML; app.unmount(); div.remove(); resolve(html); }).catch(err => { console.error('渲染失败:', err); app.unmount(); div.remove(); resolve('<div>渲染失败</div>'); }); }); }
四 封装打印
// src/utils/print/printHtml.js import { PrintTemplateType } from "@/views/print/printTemplate/printConstants"; /** * 精准打印指定HTML(无浏览器默认页眉页脚) * @param {string} html - 要打印的HTML内容 */ export function printHtml(html, { templateType = PrintTemplateType.Invoice, templateWidth = 210, templateHeight = 297 }) { // 1 根据类型调整默认参数 if (templateType === PrintTemplateType.Ticket) { templateWidth = 80; // 热敏小票通常80mm宽 templateHeight = 0; // 高度自动 } else if (templateType === PrintTemplateType.Label) { templateWidth = templateWidth || 50; // 标签打印机常见宽度50mm templateHeight = templateHeight || 30; // 标签常见高度30mm } // 1. 创建打印专用容器 const printContainer = document.createElement('div'); printContainer.id = 'print-container'; document.body.appendChild(printContainer); // 2. 注入打印控制样式(隐藏页眉页脚) const style = document.createElement('style'); style.innerHTML = ` /* 打印页面设置 */ @page { margin: 0; /* 去除页边距 */ size: ${templateWidth}mm ${templateHeight === 0 ? 'auto' : `${templateHeight}mm`}; /* 自定义纸张尺寸 */ } @media print { body, html { width: ${templateWidth}mm !important; margin: 0 !important; padding: 0 !important; background: #fff !important; /* 强制白色背景 */ } /* 隐藏页面所有元素 */ body * { visibility: hidden; } /* 只显示打印容器内容 */ #print-container, #print-container * { visibility: visible; } /* 打印容器定位 */ #print-container { position: absolute; left: 0; top: 0; width: ${templateWidth}mm !important; ${templateHeight === 0 ? 'auto' : `height: ${templateHeight}mm !important;`} margin: 0 !important; padding: 0 !important; box-sizing: border-box; page-break-after: avoid; /* 避免分页 */ page-break-inside: avoid; } } /* 屏幕预览样式 */ #print-container { width: ${templateWidth}mm; ${templateHeight === 0 ? 'auto' : `height: ${templateHeight}mm;`} // margin: 10px auto; // padding: 5mm; box-shadow: 0 0 5px rgba(0,0,0,0.2); background: white; } `; document.head.appendChild(style); // 3. 放入要打印的内容 printContainer.innerHTML = html; // 4. 触发打印 window.print(); // 5. 清理(延迟确保打印完成) setTimeout(() => { document.body.removeChild(printContainer); document.head.removeChild(style); }, 1000); }
五 封装导出PDF
// /src/utils/print/pdfExport.js import html2canvas from 'html2canvas'; import { jsPDF } from 'jspdf'; import { PrintTemplateType } from "@/views/print/printTemplate/printConstants"; // 毫米转像素的转换系数 (96dpi下) const MM_TO_PX = 3.779527559; // 默认A4尺寸 (单位: mm) const DEFAULT_WIDTH = 210; const DEFAULT_HEIGHT = 297; export async function exportToPDF(html, { filename, templateType = PrintTemplateType.Invoice, templateWidth = DEFAULT_WIDTH, templateHeight = DEFAULT_HEIGHT, allowPaging = true }) { // 生成文件名 const finalFilename = filename || `${templateType}_${Date.now()}.pdf`; // 处理宽度和高度,如果为0则使用默认值 const widthMm = templateWidth === 0 ? DEFAULT_WIDTH : templateWidth; // 分页模式使用A4高度,单页模式自动高度 const heightMm = templateHeight === 0 ? (allowPaging ? DEFAULT_HEIGHT : 'auto') : templateHeight; // 创建临时容器 const container = document.createElement('div'); container.style.position = 'absolute'; // 使容器脱离正常文档流 container.style.left = '-9999px'; // 移出可视区域,避免在页面上显示 container.style.width = `${widthMm}mm`; // 容器宽度 container.style.height = 'auto'; // 让内容决定高度 container.style.overflow = 'visible'; // 溢出部分不被裁剪 container.innerHTML = html; // 添加HTML内容 document.body.appendChild(container); // 将准备好的临时容器添加到文档中 try { if (allowPaging) { console.log('导出PDF - 分页处理模式'); const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: [widthMm, heightMm] }); // 获取所有页面或使用容器作为单页 const pageElements = container.querySelectorAll('.page'); const pages = pageElements.length > 0 ? pageElements : [container]; for (let i = 0; i < pages.length; i++) { const page = pages[i]; page.style.backgroundColor = 'white'; // 计算页面高度(像素) const pageHeightPx = page.scrollHeight; const pageHeightMm = pageHeightPx / MM_TO_PX; const canvas = await html2canvas(page, { scale: 2, useCORS: true, // 启用跨域访问 backgroundColor: '#FFFFFF', logging: true, width: widthMm * MM_TO_PX, // 画布 宽度转换成像素 height: pageHeightPx, // 画布 高度转换成像素 windowWidth: widthMm * MM_TO_PX, // 模拟视口 宽度转换成像素 windowHeight: pageHeightPx // 模拟视口 高度转换成像素 }); const imgData = canvas.toDataURL('image/png'); const imgWidth = widthMm; const imgHeight = (canvas.height * imgWidth) / canvas.width; if (i > 0) { pdf.addPage([widthMm, heightMm], 'portrait'); } pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight); } pdf.save(finalFilename); } else { console.log('导出PDF - 单页处理模式'); const canvas = await html2canvas(container, { scale: 2, useCORS: true, backgroundColor: '#FFFFFF', logging: true, width: widthMm * MM_TO_PX, height: container.scrollHeight, windowWidth: widthMm * MM_TO_PX, windowHeight: container.scrollHeight }); const imgData = canvas.toDataURL('image/png'); const imgWidth = widthMm; const imgHeight = (canvas.height * imgWidth) / canvas.width; const pdf = new jsPDF({ orientation: imgWidth > imgHeight ? 'landscape' : 'portrait', unit: 'mm', format: [imgWidth, imgHeight] }); pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight); pdf.save(finalFilename); } } catch (error) { console.error('PDF导出失败:', error); throw error; } finally { document.body.removeChild(container); } }
六 测试打印
1 封装打印预览界面
方便调试模板,此处就不提供预览界面的代码里,自己手搓吧!
2 使用浏览器默认打印
1 查看打印预览,正常打印预览与预期一样; 2 擦和看打印结果;
3 注意事项
1 涉及的模板尺寸 与 打印纸张的尺寸 要匹配; -- 否则预览界面异常、打印结果异常; 2 处理自动分页,页眉页脚留够空间,否则会覆盖; 3 有些打印机调试需要设置打印机的首选项,主要设置尺寸!
七 问题解决
// 1 打印预览样式与模板不一致 -- 检查 @media print{} 这里的样式, -- 分别检查模板 和 打印封装; // 2 打印预览异常、打印正常 -- 问题原因:打印机纸张尺寸识别异常,即打印机当前设置的尺寸与模板尺寸不一致; -- 解决办法:设置打印机 -> 首选项 -> 添加尺寸设置; // 3 打印机实测: -- 目前A4打印机、80热敏打印机、标签打印机 都有测试,没有问题! -- 如果字体很丑,建议选择等宽字体; -- 调节字体尺寸、颜色、尽可能美观、节省纸张! // 4 进一步封装 -- 项目中可以进一步封装打印,向所有流程封装到一个service中,打印只需要传递 printData、templateType; -- 可以封装批量打印; -- 模板可以根据用户自定义配置,通过pinia维护状态; // 5 后端来实现打印数据生成 -- 我是前端能做的尽可能不放到后端处理,减少后端请求处理压力!