记录---vue3项目实战 打印、导出PDF
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
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 后端来实现打印数据生成
-- 我是前端能做的尽可能不放到后端处理,减少后端请求处理压力!
本文转载于:https://juejin.cn/post/7521356618174021674
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。


浙公网安备 33010602011771号