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;
grid-template-columns: repeat(auto-fill, 50mm);
grid-auto-rows: 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 模板设计
-- invoice-A4发票 ticket-80mm热敏小票 label-货架标签
-- src/compoments/print/template/invoice/...
-- src/compoments/print/template/ticket/...
-- src/compoments/print/template/label/...
-- 前端实现模板获取
2 封装模板获取
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) {
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';
function generateBarcodeBase64(code) {
if (!code) return '';
const canvas = document.createElement('canvas');
try {
JsBarcode(canvas, code, {
format: 'CODE128',
displayValue: false,
width: 2,
height: 40,
margin: 0,
});
return canvas.toDataURL('image/png');
} catch (err) {
console.warn('条码生成失败:', err);
return '';
}
}
function getImageUrl(imgSrc) {
if (!imgSrc) {
return ''
}
try {
const src = imgSrc.split(",")[0].trim();
return isExternal(src) ? src : `${import.meta.env.VITE_APP_BASE_API}${src}`;
} catch (err) {
console.warn('图片路径拼接失败:', err);
return '';
}
}
async function generateQRCode(url) {
if (!url) return '';
try {
return await QRCode.toDataURL(url.toString())
} catch (err) {
console.warn('QR码生成失败:', err);
return '';
}
}
export default async function renderTemplate(Component, printData) {
if (!printData || typeof printData !== 'object') {
throw new Error('Invalid data format');
}
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 : [],
};
try {
if (data.invoice.invoiceQr) {
data.invoice.invoiceQrImage = await generateQRCode(data.invoice.invoiceQr);
}
if (data.detailList.length > 0) {
data.detailList = data.detailList.map(item => ({
...item,
skuCodeImg: item.skuCode ? generateBarcodeBase64(item.skuCode) : ''
}));
}
if (data.tenant.logo) {
data.tenant.logoImage = getImageUrl(data.tenant?.logo);
}
} catch (err) {
console.error('数据处理失败:', err);
}
const div = document.createElement('div');
div.id = 'print-template-container';
return new Promise((resolve) => {
const app = createApp({
render: () => h(Component, { data })
});
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
templateHeight = 0
} else if (templateType === PrintTemplateType.Label) {
templateWidth = templateWidth || 50
templateHeight = templateHeight || 30
}
// 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:
}
/* 隐藏页面所有元素 */
body * {
visibility: hidden
}
/* 只显示打印容器内容 */
visibility: visible
}
/* 打印容器定位 */
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
}
}
/* 屏幕预览样式 */
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
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
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: '
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: '
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 打印预览样式与模板不一致
// 2 打印预览异常、打印正常
// 3 打印机实测:
// 4 进一步封装
// 5 后端来实现打印数据生成
暂时能回忆起来的就这么多了,有需要更多可以死信,一起学习!!