usePrint
打印功能组合函数,提供页面打印、PDF生成、打印预览和打印设置等功能,支持自定义打印样式和多种打印模式。
📋 功能特性
- 页面打印: 打印当前页面或指定区域
- PDF生成: 将页面内容生成PDF文件
- 打印预览: 打印前预览效果
- 自定义样式: 专用打印样式表
- 批量打印: 支持多页面批量打印
- 打印设置: 纸张大小、方向、边距等配置
🎯 基础用法
简单页面打印
vue
<template>
<div>
<div id="print-content">
<h1>打印标题</h1>
<p>这是要打印的内容...</p>
<table class="print-table">
<thead>
<tr>
<th>姓名</th>
<th>部门</th>
<th>薪资</th>
</tr>
</thead>
<tbody>
<tr v-for="item in tableData" :key="item.id">
<td>{{ item.name }}</td>
<td>{{ item.department }}</td>
<td>{{ item.salary }}</td>
</tr>
</tbody>
</table>
</div>
<div class="print-actions no-print">
<el-button @click="printPage" type="primary">
打印页面
</el-button>
<el-button @click="printArea('#print-content')">
打印指定区域
</el-button>
<el-button @click="showPreview">
打印预览
</el-button>
<el-button @click="exportToPDF">
导出PDF
</el-button>
</div>
<!-- 打印预览对话框 -->
<el-dialog v-model="previewVisible" title="打印预览" width="80%">
<div v-html="previewContent" class="preview-container"></div>
</el-dialog>
</div>
</template>
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
printPage,
printArea,
exportToPDF,
showPreview,
previewVisible,
previewContent
} = usePrint()
const tableData = ref([
{ id: 1, name: '张三', department: '技术部', salary: '15000' },
{ id: 2, name: '李四', department: '市场部', salary: '12000' }
])
</script>
<style>
/* 打印专用样式 */
@media print {
.no-print {
display: none !important;
}
.print-table {
width: 100%;
border-collapse: collapse;
}
.print-table th,
.print-table td {
border: 1px solid #000;
padding: 8px;
text-align: left;
}
}
</style>自定义打印配置
vue
<template>
<div>
<!-- 打印设置面板 -->
<el-card class="print-settings">
<template #header>
<span>打印设置</span>
</template>
<el-form :model="printConfig" label-width="100px">
<el-form-item label="纸张大小">
<el-select v-model="printConfig.paperSize">
<el-option label="A4" value="A4" />
<el-option label="A3" value="A3" />
<el-option label="Letter" value="Letter" />
</el-select>
</el-form-item>
<el-form-item label="方向">
<el-radio-group v-model="printConfig.orientation">
<el-radio label="portrait">纵向</el-radio>
<el-radio label="landscape">横向</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="边距">
<el-input-number
v-model="printConfig.margin"
:min="0"
:max="50"
label="边距(mm)" />
</el-form-item>
<el-form-item label="打印选项">
<el-checkbox-group v-model="printConfig.options">
<el-checkbox label="headerFooter">页眉页脚</el-checkbox>
<el-checkbox label="background">背景图形</el-checkbox>
<el-checkbox label="selection">仅选中内容</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<div class="settings-actions">
<el-button @click="printWithConfig" type="primary">
应用设置并打印
</el-button>
<el-button @click="saveSettings">
保存设置
</el-button>
<el-button @click="resetSettings">
重置设置
</el-button>
</div>
</el-card>
<!-- 要打印的内容 -->
<div id="custom-print-area" class="print-content">
<h2>自定义打印内容</h2>
<p>这里是按照自定义设置打印的内容...</p>
</div>
</div>
</template>
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
printConfig,
printWithConfig,
saveSettings,
resetSettings,
setPrintStyle
} = usePrint()
// 设置自定义打印样式
const customPrintStyle = `
@page {
size: A4 portrait;
margin: 20mm;
}
.print-content {
font-family: "SimSun", serif;
font-size: 14px;
line-height: 1.6;
}
.print-content h2 {
color: #333;
border-bottom: 2px solid #000;
padding-bottom: 10px;
}
`
setPrintStyle(customPrintStyle)
</script>📄 PDF生成
基础PDF导出
vue
<template>
<div>
<div ref="pdfContent" class="pdf-content">
<h1>PDF文档标题</h1>
<div class="content-section">
<h2>章节一</h2>
<p>这是第一章节的内容...</p>
</div>
<div class="content-section">
<h2>章节二</h2>
<p>这是第二章节的内容...</p>
<!-- 图表 -->
<div ref="chartContainer" class="chart-container">
<!-- ECharts图表 -->
</div>
</div>
</div>
<div class="pdf-actions">
<el-button @click="generatePDF" type="primary">
生成PDF
</el-button>
<el-button @click="generatePDFWithOptions">
高级PDF生成
</el-button>
<el-button @click="batchExportPDF">
批量导出PDF
</el-button>
</div>
</div>
</template>
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
generatePDF,
generatePDFWithOptions,
batchExportPDF,
setPDFOptions
} = usePrint()
const pdfContent = ref()
// 基础PDF生成
const generatePDF = () => {
exportToPDF({
element: pdfContent.value,
filename: 'document.pdf'
})
}
// 高级PDF生成选项
const generatePDFWithOptions = () => {
const options = {
element: pdfContent.value,
filename: '高级文档.pdf',
format: 'A4',
orientation: 'portrait',
border: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
},
header: {
height: '20mm',
contents: '<div style="text-align: center;">{{page}}/{{pages}}</div>'
},
footer: {
height: '20mm',
contents: '<div style="text-align: center;">© 2024 公司名称</div>'
},
quality: 2, // 图片质量
type: 'pdf',
timeout: 30000
}
exportToPDF(options)
}
// 批量PDF导出
const batchExportPDF = () => {
const documents = [
{
element: '#doc1',
filename: '文档1.pdf'
},
{
element: '#doc2',
filename: '文档2.pdf'
}
]
batchExportPDF(documents)
}
</script>PDF水印和安全
vue
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
addWatermark,
setPDFSecurity,
generateSecurePDF
} = usePrint()
// 添加水印
const createPDFWithWatermark = () => {
const watermarkOptions = {
text: '机密文档',
opacity: 0.3,
fontSize: 48,
color: '#ff0000',
angle: -45,
position: 'center'
}
addWatermark(watermarkOptions)
generatePDF({
element: '#secure-content',
filename: '带水印文档.pdf'
})
}
// PDF安全设置
const createSecurePDF = () => {
const securityOptions = {
userPassword: 'user123',
ownerPassword: 'owner456',
permissions: {
printing: 'highResolution', // 'lowResolution' | 'highResolution' | false
modifying: false,
copying: false,
annotating: true,
fillingForms: true,
contentAccessibility: true,
documentAssembly: false
}
}
generateSecurePDF({
element: '#secure-content',
filename: '加密文档.pdf',
security: securityOptions
})
}
</script>🖼️ 打印预览
自定义预览组件
vue
<template>
<div>
<!-- 触发预览 -->
<el-button @click="openPreview" type="primary">
打印预览
</el-button>
<!-- 预览对话框 -->
<el-dialog
v-model="previewVisible"
title="打印预览"
width="90%"
top="5vh"
:close-on-click-modal="false">
<div class="preview-toolbar">
<el-button-group>
<el-button @click="zoomIn" :disabled="zoom >= 200">
<el-icon><ZoomIn /></el-icon>
放大
</el-button>
<el-button @click="zoomOut" :disabled="zoom <= 50">
<el-icon><ZoomOut /></el-icon>
缩小
</el-button>
<el-button @click="resetZoom">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-button-group>
<span class="zoom-info">{{ zoom }}%</span>
<el-button-group>
<el-button @click="prevPage" :disabled="!hasPrevPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button @click="nextPage" :disabled="!hasNextPage">
<el-icon><ArrowRight /></el-icon>
下一页
</el-button>
</el-button-group>
<span class="page-info">{{ currentPage }}/{{ totalPages }}</span>
</div>
<div class="preview-container" :style="{ transform: `scale(${zoom / 100})` }">
<div v-html="previewContent" class="preview-page"></div>
</div>
<template #footer>
<el-button @click="previewVisible = false">
取消
</el-button>
<el-button @click="confirmPrint" type="primary">
确认打印
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
// 预览相关
previewVisible,
previewContent,
openPreview,
// 缩放控制
zoom,
zoomIn,
zoomOut,
resetZoom,
// 分页控制
currentPage,
totalPages,
hasPrevPage,
hasNextPage,
prevPage,
nextPage,
// 确认打印
confirmPrint
} = usePrint()
</script>
<style scoped>
.preview-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
}
.preview-container {
max-height: 60vh;
overflow: auto;
border: 1px solid #ddd;
background: white;
transform-origin: top left;
}
.preview-page {
min-height: 100%;
padding: 20px;
}
</style>🔧 高级功能
批量打印管理
vue
<template>
<div>
<el-card class="batch-print-manager">
<template #header>
<span>批量打印管理</span>
</template>
<!-- 打印队列 -->
<div class="print-queue">
<h3>打印队列</h3>
<el-table :data="printQueue" style="width: 100%">
<el-table-column prop="name" label="文档名称" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度">
<template #default="{ row }">
<el-progress :percentage="row.progress" />
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button @click="pauseJob(row.id)" size="small">
暂停
</el-button>
<el-button @click="cancelJob(row.id)" size="small" type="danger">
取消
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 批量操作 -->
<div class="batch-actions">
<el-button @click="addToBatch" type="primary">
添加到批量打印
</el-button>
<el-button @click="startBatchPrint" :disabled="printQueue.length === 0">
开始批量打印
</el-button>
<el-button @click="clearQueue">
清空队列
</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
// 批量打印
printQueue,
addToBatch,
startBatchPrint,
clearQueue,
// 任务控制
pauseJob,
cancelJob,
resumeJob,
// 状态监听
onJobStatusChange,
onBatchComplete
} = usePrint()
// 获取状态类型
const getStatusType = (status) => {
const statusMap = {
'pending': '',
'printing': 'warning',
'completed': 'success',
'failed': 'danger',
'paused': 'info'
}
return statusMap[status] || ''
}
// 监听任务状态变化
onJobStatusChange((job) => {
console.log(`任务 ${job.name} 状态变更为: ${job.status}`)
})
// 监听批量打印完成
onBatchComplete((results) => {
const successCount = results.filter(r => r.success).length
const totalCount = results.length
ElMessage.success(`批量打印完成: ${successCount}/${totalCount} 成功`)
})
</script>打印模板系统
vue
<script setup>
import { usePrint } from '@/composables/usePrint'
const {
createTemplate,
useTemplate,
getTemplate,
listTemplates,
deleteTemplate
} = usePrint()
// 创建打印模板
const createReportTemplate = () => {
const template = {
name: '财务报表模板',
description: '标准财务报表打印模板',
style: `
@page {
size: A4 portrait;
margin: 25mm;
}
.report-header {
text-align: center;
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
}
.report-table {
width: 100%;
border-collapse: collapse;
}
.report-table th,
.report-table td {
border: 1px solid #000;
padding: 8px;
text-align: right;
}
`,
layout: `
<div class="report-header">
{{title}}
</div>
<div class="report-meta">
<p>报表日期: {{date}}</p>
<p>制表人: {{creator}}</p>
</div>
<table class="report-table">
<thead>
<tr>
<th>项目</th>
<th>金额</th>
<th>比例</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{name}}</td>
<td>{{amount}}</td>
<td>{{percentage}}</td>
</tr>
{{/each}}
</tbody>
</table>
`,
variables: {
title: '月度财务报表',
date: '2024-12-20',
creator: '财务部',
items: []
}
}
createTemplate(template)
}
// 使用模板打印
const printWithTemplate = () => {
const data = {
title: '2024年12月财务报表',
date: '2024-12-20',
creator: '张三',
items: [
{ name: '收入', amount: '1,000,000', percentage: '100%' },
{ name: '成本', amount: '600,000', percentage: '60%' },
{ name: '利润', amount: '400,000', percentage: '40%' }
]
}
useTemplate('财务报表模板', data)
}
</script>📚 API 参考
基础打印
| 方法 | 类型 | 描述 |
|---|---|---|
| printPage | () => void | 打印整个页面 |
| printArea | (selector: string) => void | 打印指定区域 |
| printElement | (element: HTMLElement) => void | 打印指定元素 |
PDF功能
| 方法 | 类型 | 描述 |
|---|---|---|
| exportToPDF | (options: PDFOptions) => Promise<void> | 导出为PDF |
| generatePDF | (config: PDFConfig) => Promise<Blob> | 生成PDF数据 |
| batchExportPDF | (docs: PDFDocument[]) => Promise<void> | 批量PDF导出 |
预览功能
| 属性/方法 | 类型 | 描述 |
|---|---|---|
| previewVisible | Ref<boolean> | 预览对话框可见性 |
| previewContent | Ref<string> | 预览内容HTML |
| showPreview | (element?: string) => void | 显示打印预览 |
配置管理
| 方法 | 类型 | 描述 |
|---|---|---|
| setPrintConfig | (config: PrintConfig) => void | 设置打印配置 |
| getPrintConfig | () => PrintConfig | 获取当前打印配置 |
| resetPrintConfig | () => void | 重置打印配置 |
🎯 最佳实践
打印样式优化
- 专用打印样式:使用
@media print定义专用样式 - 隐藏无关元素:使用
.no-print类隐藏不需要打印的元素 - 页面布局:合理设置页边距和页面尺寸
- 字体选择:使用打印友好的字体
性能优化
- 图片优化:适当压缩图片,使用矢量图标
- 分页处理:避免表格跨页断裂
- 内存管理:及时清理打印资源
- 异步处理:大文档使用异步生成
用户体验
- 进度反馈:显示打印/PDF生成进度
- 错误处理:友好的错误提示和重试机制
- 预览功能:提供打印前预览
- 设置保存:记住用户的打印偏好设置
❓ 常见问题
1. 打印内容显示不完整或样式丢失
问题描述
调用打印功能后,打印预览或实际打印结果中部分样式丢失,如背景色、边框、图标等不显示,或者内容被截断。
问题原因
- 浏览器默认禁用背景色和背景图片打印以节省墨水
- 打印样式表
@media print未正确配置 - 动态渲染内容未完成时就触发打印
- CSS选择器优先级问题导致样式被覆盖
- 使用了浏览器不支持的打印CSS属性
解决方案
vue
<template>
<div>
<!-- 打印区域 -->
<div ref="printAreaRef" class="print-area">
<div class="print-header">
<img :src="logoUrl" class="logo print-logo" />
<h1>{{ documentTitle }}</h1>
</div>
<div class="print-content">
<table class="data-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.prop">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableData" :key="row.id">
<td v-for="col in columns" :key="col.prop">{{ row[col.prop] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<el-button @click="safePrint" type="primary">安全打印</el-button>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const printAreaRef = ref<HTMLElement>()
const logoUrl = ref('/logo.png')
const documentTitle = ref('报表标题')
const columns = ref([
{ prop: 'name', label: '姓名' },
{ prop: 'dept', label: '部门' }
])
const tableData = ref([
{ id: 1, name: '张三', dept: '技术部' }
])
// ❌ 错误做法 - 直接打印可能导致内容不完整
const wrongPrint = () => {
window.print()
}
// ✅ 正确做法 - 等待内容渲染完成后再打印
const safePrint = async () => {
// 1. 等待动态内容渲染完成
await nextTick()
// 2. 等待图片加载完成
const images = printAreaRef.value?.querySelectorAll('img') || []
await Promise.all(
Array.from(images).map(img => {
if (img.complete) return Promise.resolve()
return new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
})
)
// 3. 小延迟确保样式应用
await new Promise(resolve => setTimeout(resolve, 100))
// 4. 执行打印
window.print()
}
</script>
<style lang="scss">
/* ✅ 正确的打印样式配置 */
@media print {
/* 强制打印背景色和图片 */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
/* 隐藏非打印元素 */
.no-print,
.el-button,
nav,
footer {
display: none !important;
}
/* 确保打印区域样式 */
.print-area {
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
}
/* Logo图片确保显示 */
.print-logo {
display: block !important;
max-width: 150px;
height: auto;
}
/* 表格样式 */
.data-table {
width: 100% !important;
border-collapse: collapse !important;
th, td {
border: 1px solid #000 !important;
padding: 8px !important;
background-color: transparent !important;
}
th {
background-color: #f0f0f0 !important;
font-weight: bold !important;
}
}
}
</style>关键要点:
- 使用
-webkit-print-color-adjust: exact强制打印背景 - 等待所有图片加载完成后再触发打印
- 为打印样式使用
!important确保优先级 - 使用
@media print定义专用打印样式
2. PDF生成时图片无法显示
问题描述
使用html2canvas或类似库生成PDF时,页面中的图片显示为空白或报跨域错误,特别是外部图片或Canvas图表。
问题原因
- 图片跨域(CORS)导致无法被html2canvas捕获
- 图片未加载完成就开始生成PDF
- SVG图标无法正确转换为Canvas
- Base64图片格式不正确
- 服务器未配置正确的CORS响应头
解决方案
typescript
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
interface PDFGeneratorOptions {
element: HTMLElement
filename: string
scale?: number
useCORS?: boolean
allowTaint?: boolean
imageTimeout?: number
}
/**
* 安全的PDF生成器
* 处理图片跨域和加载问题
*/
class SafePDFGenerator {
private options: PDFGeneratorOptions
constructor(options: PDFGeneratorOptions) {
this.options = {
scale: 2,
useCORS: true,
allowTaint: false,
imageTimeout: 15000,
...options
}
}
/**
* 预处理图片 - 转换为Base64
*/
private async preprocessImages(element: HTMLElement): Promise<Map<string, string>> {
const imageMap = new Map<string, string>()
const images = element.querySelectorAll('img')
for (const img of Array.from(images)) {
const src = img.src
if (!src || src.startsWith('data:')) continue
try {
const base64 = await this.imageToBase64(src)
imageMap.set(src, base64)
img.src = base64
} catch (error) {
console.warn(`图片加载失败: ${src}`, error)
// 设置占位图
img.src = this.createPlaceholderImage(img.width, img.height)
}
}
return imageMap
}
/**
* 图片转Base64
*/
private async imageToBase64(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
const timeoutId = setTimeout(() => {
reject(new Error('图片加载超时'))
}, this.options.imageTimeout)
img.onload = () => {
clearTimeout(timeoutId)
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('无法创建Canvas上下文'))
return
}
ctx.drawImage(img, 0, 0)
resolve(canvas.toDataURL('image/png'))
} catch (error) {
reject(error)
}
}
img.onerror = () => {
clearTimeout(timeoutId)
reject(new Error(`图片加载失败: ${url}`))
}
// 添加时间戳避免缓存问题
img.src = url.includes('?') ? `${url}&t=${Date.now()}` : `${url}?t=${Date.now()}`
})
}
/**
* 创建占位图
*/
private createPlaceholderImage(width: number, height: number): string {
const canvas = document.createElement('canvas')
canvas.width = width || 100
canvas.height = height || 100
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#999'
ctx.font = '14px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('图片加载失败', canvas.width / 2, canvas.height / 2)
}
return canvas.toDataURL('image/png')
}
/**
* SVG转换为Canvas
*/
private async convertSVGToCanvas(element: HTMLElement): Promise<void> {
const svgs = element.querySelectorAll('svg')
for (const svg of Array.from(svgs)) {
try {
const svgData = new XMLSerializer().serializeToString(svg)
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = svg.clientWidth || 100
canvas.height = svg.clientHeight || 100
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0)
const parent = svg.parentNode
if (parent) {
const replacement = document.createElement('img')
replacement.src = canvas.toDataURL('image/png')
replacement.style.width = `${canvas.width}px`
replacement.style.height = `${canvas.height}px`
parent.replaceChild(replacement, svg)
}
}
URL.revokeObjectURL(url)
resolve()
}
img.onerror = reject
img.src = url
})
} catch (error) {
console.warn('SVG转换失败', error)
}
}
}
/**
* 生成PDF
*/
async generate(): Promise<Blob> {
const { element, filename, scale, useCORS, allowTaint } = this.options
// 1. 克隆元素避免修改原始DOM
const clonedElement = element.cloneNode(true) as HTMLElement
document.body.appendChild(clonedElement)
clonedElement.style.position = 'absolute'
clonedElement.style.left = '-9999px'
clonedElement.style.top = '0'
try {
// 2. 预处理图片
await this.preprocessImages(clonedElement)
// 3. 转换SVG
await this.convertSVGToCanvas(clonedElement)
// 4. 等待渲染
await new Promise(resolve => setTimeout(resolve, 200))
// 5. 使用html2canvas生成Canvas
const canvas = await html2canvas(clonedElement, {
scale,
useCORS,
allowTaint,
logging: false,
backgroundColor: '#ffffff',
onclone: (clonedDoc) => {
// 确保克隆文档中的样式正确应用
const clonedEl = clonedDoc.body.querySelector('[data-pdf-content]')
if (clonedEl) {
(clonedEl as HTMLElement).style.display = 'block'
}
}
})
// 6. 生成PDF
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({
orientation: canvas.width > canvas.height ? 'landscape' : 'portrait',
unit: 'px',
format: [canvas.width, canvas.height]
})
pdf.addImage(imgData, 'PNG', 0, 0, canvas.width, canvas.height)
return pdf.output('blob')
} finally {
// 7. 清理临时元素
document.body.removeChild(clonedElement)
}
}
/**
* 下载PDF
*/
async download(): Promise<void> {
const blob = await this.generate()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = this.options.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
}
// 使用示例
const generatePDFSafely = async () => {
const element = document.getElementById('pdf-content')
if (!element) return
const generator = new SafePDFGenerator({
element,
filename: '报表.pdf',
scale: 2,
useCORS: true
})
try {
await generator.download()
ElMessage.success('PDF生成成功')
} catch (error) {
ElMessage.error('PDF生成失败,请重试')
console.error(error)
}
}后端CORS配置(必需):
java
// Spring Boot CORS配置
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
// 关键:允许前端读取图片
config.addExposedHeader("Content-Disposition");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}3. 打印预览与实际打印效果不一致
问题描述
在打印预览对话框中显示的效果与实际打印出来的效果不一致,包括布局错位、字体大小不对、分页位置不正确等。
问题原因
- 预览使用的是屏幕像素,打印使用的是物理尺寸
- 打印预览未正确模拟打印媒体查询
- 不同浏览器的打印引擎实现差异
- 预览容器尺寸与实际纸张比例不匹配
- 缩放比例计算错误
解决方案
vue
<template>
<div>
<!-- 打印内容 -->
<div ref="printContentRef" class="print-content">
<slot />
</div>
<!-- 精确打印预览 -->
<el-dialog
v-model="previewVisible"
title="打印预览"
width="900px"
:close-on-click-modal="false"
destroy-on-close>
<div class="preview-controls">
<el-select v-model="paperSize" @change="updatePreview">
<el-option label="A4" value="A4" />
<el-option label="A3" value="A3" />
<el-option label="Letter" value="Letter" />
</el-select>
<el-radio-group v-model="orientation" @change="updatePreview">
<el-radio label="portrait">纵向</el-radio>
<el-radio label="landscape">横向</el-radio>
</el-radio-group>
<el-slider
v-model="previewScale"
:min="50"
:max="150"
:step="10"
:format-tooltip="val => `${val}%`"
/>
</div>
<div class="preview-container" ref="previewContainerRef">
<div
class="preview-page"
:style="previewPageStyle"
ref="previewPageRef">
<iframe
ref="previewIframeRef"
:srcdoc="previewHTML"
frameborder="0"
:style="iframeStyle"
/>
</div>
</div>
<template #footer>
<el-button @click="previewVisible = false">取消</el-button>
<el-button type="primary" @click="confirmPrint">确认打印</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
// 纸张尺寸定义 (mm)
const PAPER_SIZES = {
A4: { width: 210, height: 297 },
A3: { width: 297, height: 420 },
Letter: { width: 215.9, height: 279.4 }
} as const
type PaperSize = keyof typeof PAPER_SIZES
type Orientation = 'portrait' | 'landscape'
const printContentRef = ref<HTMLElement>()
const previewContainerRef = ref<HTMLElement>()
const previewPageRef = ref<HTMLElement>()
const previewIframeRef = ref<HTMLIFrameElement>()
const previewVisible = ref(false)
const paperSize = ref<PaperSize>('A4')
const orientation = ref<Orientation>('portrait')
const previewScale = ref(100)
const previewHTML = ref('')
// 获取实际纸张尺寸(考虑方向)
const actualPaperSize = computed(() => {
const size = PAPER_SIZES[paperSize.value]
return orientation.value === 'portrait'
? { width: size.width, height: size.height }
: { width: size.height, height: size.width }
})
// mm转px(按96 DPI标准)
const mmToPx = (mm: number): number => mm * 96 / 25.4
// 预览页面样式
const previewPageStyle = computed(() => {
const { width, height } = actualPaperSize.value
const scale = previewScale.value / 100
return {
width: `${mmToPx(width)}px`,
height: `${mmToPx(height)}px`,
transform: `scale(${scale})`,
transformOrigin: 'top center',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: '#fff',
margin: '0 auto',
overflow: 'hidden'
}
})
// iframe样式
const iframeStyle = computed(() => ({
width: '100%',
height: '100%',
border: 'none'
}))
/**
* 生成精确的打印预览HTML
*/
const generatePreviewHTML = (): string => {
const content = printContentRef.value?.innerHTML || ''
const { width, height } = actualPaperSize.value
// 收集当前页面样式
const styles = Array.from(document.styleSheets)
.map(sheet => {
try {
return Array.from(sheet.cssRules)
.map(rule => rule.cssText)
.join('\n')
} catch {
return ''
}
})
.join('\n')
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
/* 重置样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 模拟打印纸张 */
@page {
size: ${width}mm ${height}mm;
margin: 20mm;
}
html, body {
width: ${width}mm;
min-height: ${height}mm;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12pt;
line-height: 1.5;
color: #000;
background: #fff;
}
/* 应用打印媒体查询 */
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* 原始样式 */
${styles}
/* 强制应用打印样式 */
.no-print {
display: none !important;
}
/* 分页控制 */
.page-break-before {
page-break-before: always;
}
.page-break-after {
page-break-after: always;
}
.avoid-break {
page-break-inside: avoid;
}
</style>
</head>
<body>
${content}
</body>
</html>
`
}
/**
* 更新预览
*/
const updatePreview = async () => {
await nextTick()
previewHTML.value = generatePreviewHTML()
}
/**
* 打开预览
*/
const openPreview = async () => {
previewVisible.value = true
await updatePreview()
}
/**
* 确认打印 - 使用iframe打印确保一致性
*/
const confirmPrint = () => {
const iframe = previewIframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.focus()
iframe.contentWindow.print()
}
// 监听设置变化
watch([paperSize, orientation], updatePreview)
defineExpose({
openPreview,
confirmPrint
})
</script>
<style scoped lang="scss">
.preview-controls {
display: flex;
gap: 16px;
align-items: center;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
margin-bottom: 16px;
}
.preview-container {
max-height: 60vh;
overflow: auto;
background: #e0e0e0;
padding: 20px;
border-radius: 4px;
}
.preview-page {
transition: transform 0.2s ease;
}
</style>使用精确预览组件:
vue
<template>
<div>
<AccuratePrintPreview ref="printPreviewRef">
<div class="report-content">
<h1>财务报表</h1>
<table class="data-table">
<!-- 表格内容 -->
</table>
</div>
</AccuratePrintPreview>
<el-button @click="openPreview">打印预览</el-button>
</div>
</template>
<script setup lang="ts">
import AccuratePrintPreview from './AccuratePrintPreview.vue'
const printPreviewRef = ref()
const openPreview = () => {
printPreviewRef.value?.openPreview()
}
</script>4. 分页导致表格内容被切断
问题描述
打印多页内容时,表格行或其他内容块在页面边界处被分割,导致一行数据分布在两页上,影响可读性。
问题原因
- 浏览器默认分页算法不智能
- 未使用CSS分页控制属性
- 表格行高度超过可用空间
- 动态内容高度无法预计算
解决方案
typescript
import { ref, onMounted, nextTick } from 'vue'
interface PageBreakConfig {
pageHeight: number // 页面可用高度(px)
headerHeight: number // 每页页眉高度
footerHeight: number // 每页页脚高度
minRowsPerPage: number // 每页最少行数
}
/**
* 智能分页管理器
* 自动计算并插入分页符,避免内容被切断
*/
class SmartPageBreakManager {
private config: PageBreakConfig
private contentElement: HTMLElement | null = null
constructor(config: Partial<PageBreakConfig> = {}) {
// A4纸张默认配置 (按96 DPI)
this.config = {
pageHeight: 1123, // 297mm ≈ 1123px
headerHeight: 60,
footerHeight: 60,
minRowsPerPage: 3,
...config
}
}
/**
* 设置打印内容元素
*/
setContent(element: HTMLElement): this {
this.contentElement = element
return this
}
/**
* 获取可用页面高度
*/
private getAvailableHeight(): number {
const { pageHeight, headerHeight, footerHeight } = this.config
return pageHeight - headerHeight - footerHeight
}
/**
* 处理表格分页
*/
processTable(table: HTMLTableElement): void {
const availableHeight = this.getAvailableHeight()
const rows = Array.from(table.querySelectorAll('tbody tr'))
const thead = table.querySelector('thead')
const theadHeight = thead?.offsetHeight || 0
let currentPageHeight = theadHeight
let rowsInCurrentPage = 0
rows.forEach((row, index) => {
const rowElement = row as HTMLElement
const rowHeight = rowElement.offsetHeight
// 检查是否需要分页
if (currentPageHeight + rowHeight > availableHeight &&
rowsInCurrentPage >= this.config.minRowsPerPage) {
// 在当前行前插入分页标记
this.insertPageBreak(rowElement, thead)
currentPageHeight = theadHeight + rowHeight
rowsInCurrentPage = 1
} else {
currentPageHeight += rowHeight
rowsInCurrentPage++
}
})
}
/**
* 插入分页符和表头复制
*/
private insertPageBreak(beforeElement: HTMLElement, thead: HTMLElement | null): void {
// 创建分页符
const pageBreak = document.createElement('div')
pageBreak.className = 'print-page-break'
pageBreak.style.cssText = `
page-break-before: always;
break-before: page;
height: 0;
overflow: hidden;
`
beforeElement.parentNode?.insertBefore(pageBreak, beforeElement)
// 如果有表头,在新页复制表头
if (thead) {
const theadClone = thead.cloneNode(true) as HTMLElement
theadClone.className = 'print-thead-repeat'
theadClone.style.cssText = `
display: none;
`
// 在打印时显示
beforeElement.parentNode?.insertBefore(theadClone, beforeElement)
}
}
/**
* 处理内容块分页
*/
processContentBlocks(selector: string): void {
if (!this.contentElement) return
const blocks = Array.from(this.contentElement.querySelectorAll(selector))
const availableHeight = this.getAvailableHeight()
let currentPageHeight = 0
blocks.forEach((block, index) => {
const element = block as HTMLElement
const blockHeight = element.offsetHeight
// 标记不可分割的块
element.style.pageBreakInside = 'avoid'
element.style.breakInside = 'avoid'
if (currentPageHeight + blockHeight > availableHeight && currentPageHeight > 0) {
// 需要分页
element.style.pageBreakBefore = 'always'
element.style.breakBefore = 'page'
currentPageHeight = blockHeight
} else {
currentPageHeight += blockHeight
}
})
}
/**
* 清理分页标记
*/
cleanup(): void {
if (!this.contentElement) return
// 移除分页符
this.contentElement.querySelectorAll('.print-page-break').forEach(el => el.remove())
// 移除复制的表头
this.contentElement.querySelectorAll('.print-thead-repeat').forEach(el => el.remove())
}
}
// Vue组合式函数
export function useSmartPageBreak() {
const printContentRef = ref<HTMLElement>()
const manager = new SmartPageBreakManager()
const processPrintContent = async () => {
if (!printContentRef.value) return
await nextTick()
manager.setContent(printContentRef.value)
// 处理所有表格
const tables = printContentRef.value.querySelectorAll('table')
tables.forEach(table => manager.processTable(table as HTMLTableElement))
// 处理内容块
manager.processContentBlocks('.content-block')
}
const cleanup = () => {
manager.cleanup()
}
return {
printContentRef,
processPrintContent,
cleanup
}
}配合CSS使用:
scss
// 打印分页控制样式
@media print {
// 表格分页优化
table {
border-collapse: collapse;
width: 100%;
thead {
// 每页重复表头
display: table-header-group;
}
tbody {
display: table-row-group;
}
tfoot {
// 每页重复表尾
display: table-footer-group;
}
tr {
// 避免行被切断
page-break-inside: avoid;
break-inside: avoid;
}
}
// 内容块分页
.content-block {
page-break-inside: avoid;
break-inside: avoid;
}
// 章节标题保持与内容在同一页
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
// 复制的表头在打印时显示
.print-thead-repeat {
display: table-header-group !important;
}
// 强制分页
.page-break-before {
page-break-before: always;
break-before: page;
}
.page-break-after {
page-break-after: always;
break-after: page;
}
}5. 打印时中文乱码
问题描述
生成PDF或打印时,中文字符显示为乱码、方块或问号,特别是在使用jsPDF等库时问题更为明显。
问题原因
- jsPDF默认不支持中文字体
- 未正确嵌入中文字体文件
- 字体文件格式不兼容
- 编码问题(UTF-8未正确处理)
- 字体回退机制失效
解决方案
typescript
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
/**
* 中文PDF生成器
* 解决jsPDF中文乱码问题
*/
class ChinesePDFGenerator {
private fontLoaded = false
private fontData: string | null = null
/**
* 加载中文字体
* 使用思源黑体或其他开源中文字体
*/
async loadChineseFont(): Promise<void> {
if (this.fontLoaded) return
try {
// 方案1: 从CDN或本地加载字体
const fontUrl = '/fonts/SourceHanSansCN-Regular.ttf'
const response = await fetch(fontUrl)
const fontBuffer = await response.arrayBuffer()
// 转换为Base64
this.fontData = this.arrayBufferToBase64(fontBuffer)
this.fontLoaded = true
} catch (error) {
console.warn('字体加载失败,将使用图片模式', error)
}
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = ''
const bytes = new Uint8Array(buffer)
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
/**
* 使用字体嵌入方式生成PDF
*/
async generateWithFont(element: HTMLElement, filename: string): Promise<void> {
await this.loadChineseFont()
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'A4'
})
// 添加字体
if (this.fontData) {
pdf.addFileToVFS('SourceHanSansCN-Regular.ttf', this.fontData)
pdf.addFont('SourceHanSansCN-Regular.ttf', 'SourceHanSans', 'normal')
pdf.setFont('SourceHanSans')
}
// 提取文本内容并添加到PDF
const textContent = element.innerText
const lines = pdf.splitTextToSize(textContent, 170) // A4宽度减去边距
let y = 20
const lineHeight = 7
const pageHeight = 280
lines.forEach((line: string) => {
if (y > pageHeight) {
pdf.addPage()
y = 20
}
pdf.text(line, 20, y)
y += lineHeight
})
pdf.save(filename)
}
/**
* 使用图片模式生成PDF(推荐,兼容性最好)
*/
async generateWithCanvas(element: HTMLElement, filename: string): Promise<void> {
// 设置元素字体确保渲染正确
const originalFont = element.style.fontFamily
element.style.fontFamily = '"Microsoft YaHei", "微软雅黑", "PingFang SC", "Helvetica Neue", sans-serif'
try {
const canvas = await html2canvas(element, {
scale: 2, // 高清输出
useCORS: true,
logging: false,
// 确保中文渲染
onclone: (clonedDoc) => {
const clonedElement = clonedDoc.body.firstElementChild as HTMLElement
if (clonedElement) {
clonedElement.style.fontFamily = '"Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif'
}
}
})
const imgData = canvas.toDataURL('image/png')
const imgWidth = 210 // A4宽度mm
const imgHeight = (canvas.height * imgWidth) / canvas.width
const pdf = new jsPDF({
orientation: imgHeight > imgWidth ? 'portrait' : 'landscape',
unit: 'mm',
format: 'A4'
})
const pageHeight = pdf.internal.pageSize.getHeight()
let position = 0
// 处理多页
while (position < imgHeight) {
if (position > 0) {
pdf.addPage()
}
pdf.addImage(imgData, 'PNG', 0, -position, imgWidth, imgHeight)
position += pageHeight
}
pdf.save(filename)
} finally {
element.style.fontFamily = originalFont
}
}
}
// Vue组合式函数
export function useChinesePDF() {
const generator = new ChinesePDFGenerator()
const generatePDF = async (
element: HTMLElement | string,
filename: string,
mode: 'font' | 'canvas' = 'canvas'
) => {
const el = typeof element === 'string'
? document.querySelector(element) as HTMLElement
: element
if (!el) {
throw new Error('未找到打印元素')
}
if (mode === 'font') {
await generator.generateWithFont(el, filename)
} else {
await generator.generateWithCanvas(el, filename)
}
}
return { generatePDF }
}打印样式中的字体设置:
scss
// 确保打印时使用正确的中文字体
@media print {
* {
font-family:
"Microsoft YaHei", // Windows
"微软雅黑",
"PingFang SC", // macOS
"苹方-简",
"Hiragino Sans GB", // macOS备选
"Heiti SC", // macOS/iOS
"Source Han Sans CN", // 思源黑体
"Noto Sans CJK SC", // Google Noto
"WenQuanYi Micro Hei", // Linux
sans-serif !important;
}
// 特定元素字体强化
.print-content {
font-family: "SimSun", "宋体", serif !important; // 正式文档用宋体
}
.print-title {
font-family: "SimHei", "黑体", sans-serif !important; // 标题用黑体
}
}6. 批量打印导致浏览器崩溃或内存溢出
问题描述
批量打印大量文档或生成多个PDF时,浏览器变得卡顿、无响应,甚至崩溃,内存占用持续增长不释放。
问题原因
- 同时处理过多DOM元素导致内存溢出
- Canvas对象未及时释放
- 闭包引用导致垃圾回收失效
- 未使用队列控制并发数
- 大图片未压缩处理
解决方案
typescript
import { ref, shallowRef } from 'vue'
interface BatchPrintJob {
id: string
element: HTMLElement | string
filename: string
status: 'pending' | 'processing' | 'completed' | 'failed'
progress: number
error?: string
}
interface BatchPrintOptions {
concurrency: number // 并发数
delayBetweenJobs: number // 任务间隔(ms)
onProgress?: (job: BatchPrintJob) => void
onComplete?: (results: BatchPrintJob[]) => void
onError?: (job: BatchPrintJob, error: Error) => void
}
/**
* 批量打印管理器
* 使用队列和内存管理避免崩溃
*/
class BatchPrintManager {
private queue: BatchPrintJob[] = []
private processing = new Set<string>()
private completed: BatchPrintJob[] = []
private options: BatchPrintOptions
private abortController: AbortController | null = null
constructor(options: Partial<BatchPrintOptions> = {}) {
this.options = {
concurrency: 2, // 默认同时处理2个
delayBetweenJobs: 500,
...options
}
}
/**
* 添加打印任务
*/
addJob(job: Omit<BatchPrintJob, 'status' | 'progress'>): string {
const newJob: BatchPrintJob = {
...job,
id: job.id || `job_${Date.now()}_${Math.random().toString(36).slice(2)}`,
status: 'pending',
progress: 0
}
this.queue.push(newJob)
return newJob.id
}
/**
* 开始批量处理
*/
async start(): Promise<BatchPrintJob[]> {
this.abortController = new AbortController()
this.completed = []
const processNext = async (): Promise<void> => {
if (this.abortController?.signal.aborted) return
// 获取下一个待处理任务
const job = this.queue.find(j => j.status === 'pending')
if (!job) return
// 检查并发限制
if (this.processing.size >= this.options.concurrency) {
await new Promise(resolve => setTimeout(resolve, 100))
return processNext()
}
job.status = 'processing'
this.processing.add(job.id)
try {
await this.processJob(job)
job.status = 'completed'
job.progress = 100
this.options.onProgress?.(job)
} catch (error) {
job.status = 'failed'
job.error = error instanceof Error ? error.message : String(error)
this.options.onError?.(job, error as Error)
} finally {
this.processing.delete(job.id)
this.completed.push(job)
// 释放内存
this.releaseJobMemory(job)
// 延迟后处理下一个
if (!this.abortController?.signal.aborted) {
await new Promise(resolve =>
setTimeout(resolve, this.options.delayBetweenJobs)
)
await processNext()
}
}
}
// 启动并发任务
const workers = Array(this.options.concurrency)
.fill(null)
.map(() => processNext())
await Promise.all(workers)
this.options.onComplete?.(this.completed)
return this.completed
}
/**
* 处理单个任务
*/
private async processJob(job: BatchPrintJob): Promise<void> {
const element = typeof job.element === 'string'
? document.querySelector(job.element) as HTMLElement
: job.element
if (!element) {
throw new Error('元素不存在')
}
// 克隆元素避免影响原始DOM
const clone = element.cloneNode(true) as HTMLElement
const container = document.createElement('div')
container.style.cssText = 'position:absolute;left:-9999px;top:0;'
container.appendChild(clone)
document.body.appendChild(container)
try {
job.progress = 20
this.options.onProgress?.(job)
// 压缩图片
await this.compressImages(clone)
job.progress = 40
this.options.onProgress?.(job)
// 生成Canvas
const canvas = await this.createCanvas(clone)
job.progress = 70
this.options.onProgress?.(job)
// 生成PDF
await this.generatePDF(canvas, job.filename)
job.progress = 100
this.options.onProgress?.(job)
// 释放Canvas内存
canvas.width = 0
canvas.height = 0
} finally {
// 移除临时容器
document.body.removeChild(container)
}
}
/**
* 压缩图片减少内存占用
*/
private async compressImages(element: HTMLElement): Promise<void> {
const images = element.querySelectorAll('img')
const maxDimension = 1200 // 最大尺寸
for (const img of Array.from(images)) {
if (!img.src || img.src.startsWith('data:')) continue
try {
const compressed = await this.compressImage(img.src, maxDimension)
img.src = compressed
} catch (error) {
console.warn('图片压缩失败', error)
}
}
}
private async compressImage(src: string, maxDimension: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
let { width, height } = img
// 计算缩放比例
if (width > maxDimension || height > maxDimension) {
const ratio = Math.min(maxDimension / width, maxDimension / height)
width *= ratio
height *= ratio
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.8))
} else {
reject(new Error('无法创建Canvas'))
}
// 释放资源
canvas.width = 0
canvas.height = 0
}
img.onerror = reject
img.src = src
})
}
private async createCanvas(element: HTMLElement): Promise<HTMLCanvasElement> {
const html2canvas = (await import('html2canvas')).default
return html2canvas(element, {
scale: 1.5, // 适当降低清晰度以节省内存
useCORS: true,
logging: false
})
}
private async generatePDF(canvas: HTMLCanvasElement, filename: string): Promise<void> {
const { jsPDF } = await import('jspdf')
const imgData = canvas.toDataURL('image/jpeg', 0.8) // 使用JPEG减少大小
const pdf = new jsPDF({
orientation: canvas.width > canvas.height ? 'landscape' : 'portrait',
unit: 'px',
format: [canvas.width, canvas.height]
})
pdf.addImage(imgData, 'JPEG', 0, 0, canvas.width, canvas.height)
pdf.save(filename)
}
/**
* 释放任务内存
*/
private releaseJobMemory(job: BatchPrintJob): void {
// 如果element是字符串选择器,不需要处理
if (typeof job.element !== 'string') {
// 清空引用
(job as any).element = null
}
// 强制垃圾回收(如果支持)
if ('gc' in window && typeof (window as any).gc === 'function') {
(window as any).gc()
}
}
/**
* 取消批量处理
*/
abort(): void {
this.abortController?.abort()
this.queue.forEach(job => {
if (job.status === 'pending') {
job.status = 'failed'
job.error = '已取消'
}
})
}
/**
* 获取进度
*/
getProgress(): { total: number; completed: number; failed: number } {
return {
total: this.queue.length,
completed: this.completed.filter(j => j.status === 'completed').length,
failed: this.completed.filter(j => j.status === 'failed').length
}
}
}
// Vue组合式函数
export function useBatchPrint() {
const manager = shallowRef<BatchPrintManager>()
const jobs = ref<BatchPrintJob[]>([])
const isProcessing = ref(false)
const progress = ref({ total: 0, completed: 0, failed: 0 })
const initManager = (options?: Partial<BatchPrintOptions>) => {
manager.value = new BatchPrintManager({
...options,
onProgress: (job) => {
const index = jobs.value.findIndex(j => j.id === job.id)
if (index > -1) {
jobs.value[index] = { ...job }
}
progress.value = manager.value!.getProgress()
},
onComplete: () => {
isProcessing.value = false
}
})
}
const addJob = (element: HTMLElement | string, filename: string) => {
if (!manager.value) initManager()
const id = manager.value!.addJob({ id: '', element, filename })
jobs.value.push({
id,
element,
filename,
status: 'pending',
progress: 0
})
return id
}
const startBatch = async () => {
if (!manager.value) return
isProcessing.value = true
await manager.value.start()
}
const abort = () => {
manager.value?.abort()
isProcessing.value = false
}
const clear = () => {
jobs.value = []
manager.value = undefined
}
return {
jobs,
isProcessing,
progress,
initManager,
addJob,
startBatch,
abort,
clear
}
}7. 打印弹窗被浏览器拦截
问题描述
调用 window.print() 或打开新窗口进行打印时,浏览器阻止弹窗,用户无法正常打印。
问题原因
- 打印操作不在用户交互事件处理函数中直接调用
- 异步操作后再调用打印被认为是非用户触发
- 新窗口打印被浏览器弹窗拦截器阻止
- 多次快速调用打印被拦截
解决方案
vue
<template>
<div>
<!-- 打印内容 -->
<div ref="printContentRef" class="print-content">
<h1>{{ title }}</h1>
<div v-html="content"></div>
</div>
<!-- 打印按钮 -->
<el-button @click="handlePrint" type="primary" :loading="isPrinting">
打印
</el-button>
<!-- 隐藏iframe用于打印 -->
<iframe
ref="printFrameRef"
style="display: none; width: 0; height: 0; border: none;"
title="print-frame"
/>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const printContentRef = ref<HTMLElement>()
const printFrameRef = ref<HTMLIFrameElement>()
const isPrinting = ref(false)
const title = ref('打印标题')
const content = ref('<p>打印内容</p>')
/**
* 同步打印处理器
* 确保在用户交互事件中直接调用
*/
const handlePrint = async () => {
isPrinting.value = true
try {
// 方案1: 使用隐藏iframe打印(推荐,不会被拦截)
await printViaIframe()
// 方案2: 直接打印当前页面
// await printCurrentPage()
// 方案3: 预加载后打印
// await printWithPreload()
} catch (error) {
console.error('打印失败', error)
ElMessage.error('打印失败,请重试')
} finally {
isPrinting.value = false
}
}
/**
* 方案1: 使用iframe打印(推荐)
* 不会触发弹窗拦截
*/
const printViaIframe = async (): Promise<void> => {
const iframe = printFrameRef.value
if (!iframe) throw new Error('打印框架未初始化')
const contentHtml = printContentRef.value?.innerHTML || ''
// 收集样式
const styles = collectStyles()
// 构建打印文档
const printDocument = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>打印</title>
<style>
${styles}
@media print {
body {
margin: 0;
padding: 20mm;
}
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>
</head>
<body>
${contentHtml}
</body>
</html>
`
// 写入iframe
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (!doc) throw new Error('无法访问iframe文档')
doc.open()
doc.write(printDocument)
doc.close()
// 等待内容加载
await new Promise<void>((resolve, reject) => {
const checkReady = () => {
if (doc.readyState === 'complete') {
resolve()
} else {
setTimeout(checkReady, 50)
}
}
// 超时处理
const timeout = setTimeout(() => {
reject(new Error('加载超时'))
}, 10000)
iframe.onload = () => {
clearTimeout(timeout)
resolve()
}
checkReady()
})
// 等待图片加载
const images = doc.querySelectorAll('img')
await Promise.all(
Array.from(images).map(img => {
if (img.complete) return Promise.resolve()
return new Promise<void>((resolve) => {
img.onload = () => resolve()
img.onerror = () => resolve() // 图片加载失败也继续
})
})
)
// 执行打印
iframe.contentWindow?.focus()
iframe.contentWindow?.print()
}
/**
* 方案2: 直接打印当前页面
* 需要确保在同步事件处理中调用
*/
const printCurrentPage = async (): Promise<void> => {
// 添加打印类
document.body.classList.add('printing')
// 使用nextTick确保DOM更新
await nextTick()
// 直接调用print(同步,不会被拦截)
window.print()
// 移除打印类
document.body.classList.remove('printing')
}
/**
* 方案3: 预加载内容后打印
* 适用于需要异步加载数据的场景
*/
const printWithPreload = async (): Promise<void> => {
// 1. 先显示加载提示
const loadingInstance = ElLoading.service({
text: '正在准备打印内容...'
})
try {
// 2. 异步加载数据
await loadPrintData()
// 3. 等待渲染
await nextTick()
// 4. 关闭loading
loadingInstance.close()
// 5. 小延迟确保用户交互上下文
await new Promise(resolve => setTimeout(resolve, 0))
// 6. 打印
window.print()
} catch (error) {
loadingInstance.close()
throw error
}
}
const loadPrintData = async () => {
// 模拟异步数据加载
await new Promise(resolve => setTimeout(resolve, 500))
}
/**
* 收集页面样式
*/
const collectStyles = (): string => {
const styles: string[] = []
// 收集所有style标签
document.querySelectorAll('style').forEach(style => {
styles.push(style.innerHTML)
})
// 收集所有link样式表
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
try {
const sheet = (link as HTMLLinkElement).sheet
if (sheet) {
const rules = Array.from(sheet.cssRules)
.map(rule => rule.cssText)
.join('\n')
styles.push(rules)
}
} catch (e) {
// 跨域样式表无法访问,忽略
}
})
return styles.join('\n')
}
</script>
<style lang="scss">
// 打印模式下的页面样式
body.printing {
// 隐藏非打印内容
> *:not(.print-content) {
display: none !important;
}
.print-content {
display: block !important;
position: static !important;
}
}
@media print {
body.printing {
margin: 0;
padding: 0;
}
}
</style>关键要点:
- 使用iframe打印是最可靠的方案,不会触发弹窗拦截
- 确保打印调用在用户交互的同步上下文中
- 异步加载数据后,使用setTimeout(0)重新进入事件循环
- 避免使用window.open进行打印,容易被拦截
8. PDF水印生成失败或位置不正确
问题描述
为PDF添加水印时,水印不显示、位置偏移、被内容遮挡,或者在不同页面上显示不一致。
问题原因
- 水印层级(z-index)设置不正确
- 坐标计算未考虑页面尺寸和边距
- Canvas绑定时机不对
- 透明度设置导致水印不可见
- 多页文档未循环添加水印
解决方案
typescript
import jsPDF from 'jspdf'
interface WatermarkOptions {
text: string
fontSize?: number
color?: string
opacity?: number
angle?: number
position?: 'center' | 'tile' | 'corner'
gap?: number // 平铺间距
}
interface PDFWithWatermarkOptions {
element: HTMLElement
filename: string
watermark: WatermarkOptions
pageSize?: 'A4' | 'A3' | 'Letter'
orientation?: 'portrait' | 'landscape'
}
/**
* PDF水印生成器
*/
class PDFWatermarkGenerator {
private defaultOptions: Required<WatermarkOptions> = {
text: '机密文档',
fontSize: 48,
color: '#000000',
opacity: 0.1,
angle: -45,
position: 'tile',
gap: 200
}
/**
* 生成带水印的PDF
*/
async generate(options: PDFWithWatermarkOptions): Promise<Blob> {
const watermarkOpts = { ...this.defaultOptions, ...options.watermark }
// 1. 生成内容Canvas
const contentCanvas = await this.createContentCanvas(options.element)
// 2. 创建PDF
const pdf = this.createPDF(contentCanvas, options)
// 3. 添加水印到每一页
const pageCount = pdf.getNumberOfPages()
for (let i = 1; i <= pageCount; i++) {
pdf.setPage(i)
this.addWatermarkToPage(pdf, watermarkOpts)
}
return pdf.output('blob')
}
/**
* 创建内容Canvas
*/
private async createContentCanvas(element: HTMLElement): Promise<HTMLCanvasElement> {
const html2canvas = (await import('html2canvas')).default
return html2canvas(element, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff'
})
}
/**
* 创建PDF文档
*/
private createPDF(canvas: HTMLCanvasElement, options: PDFWithWatermarkOptions): jsPDF {
const { pageSize = 'A4', orientation = 'portrait' } = options
const pdf = new jsPDF({
orientation,
unit: 'mm',
format: pageSize
})
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const margin = 10 // mm
const contentWidth = pageWidth - margin * 2
const contentHeight = (canvas.height * contentWidth) / canvas.width
const imgData = canvas.toDataURL('image/png')
// 处理多页
let position = margin
let remainingHeight = contentHeight
const availableHeight = pageHeight - margin * 2
while (remainingHeight > 0) {
if (position > margin) {
pdf.addPage()
position = margin
}
// 计算当前页显示高度
const currentHeight = Math.min(remainingHeight, availableHeight)
// 使用裁剪方式添加图片
pdf.addImage(
imgData,
'PNG',
margin,
position,
contentWidth,
contentHeight,
undefined,
'FAST'
)
remainingHeight -= availableHeight
position = margin - (contentHeight - remainingHeight - availableHeight)
}
return pdf
}
/**
* 添加水印到单页
*/
private addWatermarkToPage(pdf: jsPDF, options: Required<WatermarkOptions>): void {
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
// 保存当前状态
pdf.saveGraphicsState()
// 设置透明度
pdf.setGState(new (pdf as any).GState({ opacity: options.opacity }))
// 设置字体
pdf.setFontSize(options.fontSize)
pdf.setTextColor(options.color)
switch (options.position) {
case 'center':
this.addCenterWatermark(pdf, options, pageWidth, pageHeight)
break
case 'tile':
this.addTileWatermark(pdf, options, pageWidth, pageHeight)
break
case 'corner':
this.addCornerWatermark(pdf, options, pageWidth, pageHeight)
break
}
// 恢复状态
pdf.restoreGraphicsState()
}
/**
* 中心水印
*/
private addCenterWatermark(
pdf: jsPDF,
options: Required<WatermarkOptions>,
pageWidth: number,
pageHeight: number
): void {
const textWidth = pdf.getTextWidth(options.text)
const centerX = pageWidth / 2
const centerY = pageHeight / 2
// 旋转文字
pdf.text(options.text, centerX, centerY, {
angle: options.angle,
align: 'center',
baseline: 'middle'
})
}
/**
* 平铺水印
*/
private addTileWatermark(
pdf: jsPDF,
options: Required<WatermarkOptions>,
pageWidth: number,
pageHeight: number
): void {
const { text, gap, angle } = options
const textWidth = pdf.getTextWidth(text)
// 计算旋转后的有效间距
const radians = (angle * Math.PI) / 180
const horizontalGap = gap
const verticalGap = gap * 0.75
// 扩展绘制范围以覆盖旋转后的角落
const extendedWidth = pageWidth + pageHeight * Math.abs(Math.sin(radians))
const extendedHeight = pageHeight + pageWidth * Math.abs(Math.sin(radians))
const startX = -pageHeight * Math.abs(Math.sin(radians)) / 2
const startY = -pageWidth * Math.abs(Math.sin(radians)) / 2
for (let y = startY; y < extendedHeight; y += verticalGap) {
for (let x = startX; x < extendedWidth; x += horizontalGap) {
pdf.text(text, x, y, {
angle,
align: 'center',
baseline: 'middle'
})
}
}
}
/**
* 角落水印
*/
private addCornerWatermark(
pdf: jsPDF,
options: Required<WatermarkOptions>,
pageWidth: number,
pageHeight: number
): void {
const margin = 15
const positions = [
{ x: margin, y: margin, align: 'left' },
{ x: pageWidth - margin, y: margin, align: 'right' },
{ x: margin, y: pageHeight - margin, align: 'left' },
{ x: pageWidth - margin, y: pageHeight - margin, align: 'right' }
]
positions.forEach(pos => {
pdf.text(options.text, pos.x, pos.y, {
angle: options.angle,
align: pos.align as any,
baseline: 'middle'
})
})
}
}
/**
* Canvas水印生成器
* 用于html2canvas前添加水印
*/
class CanvasWatermarkGenerator {
/**
* 为Canvas添加水印层
*/
addWatermark(canvas: HTMLCanvasElement, options: WatermarkOptions): HTMLCanvasElement {
const ctx = canvas.getContext('2d')
if (!ctx) return canvas
const {
text = '机密文档',
fontSize = 24,
color = '#000000',
opacity = 0.1,
angle = -45,
position = 'tile',
gap = 150
} = options
// 保存当前状态
ctx.save()
// 设置样式
ctx.globalAlpha = opacity
ctx.fillStyle = color
ctx.font = `${fontSize}px Arial, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const radians = (angle * Math.PI) / 180
if (position === 'tile') {
// 平铺水印
for (let y = 0; y < canvas.height + gap; y += gap) {
for (let x = 0; x < canvas.width + gap; x += gap) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(radians)
ctx.fillText(text, 0, 0)
ctx.restore()
}
}
} else if (position === 'center') {
// 中心水印
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate(radians)
ctx.fillText(text, 0, 0)
}
// 恢复状态
ctx.restore()
return canvas
}
}
// Vue组合式函数
export function usePDFWatermark() {
const pdfGenerator = new PDFWatermarkGenerator()
const canvasGenerator = new CanvasWatermarkGenerator()
const generatePDFWithWatermark = async (options: PDFWithWatermarkOptions): Promise<void> => {
try {
const blob = await pdfGenerator.generate(options)
// 下载
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = options.filename
link.click()
URL.revokeObjectURL(url)
ElMessage.success('PDF生成成功')
} catch (error) {
ElMessage.error('PDF生成失败')
console.error(error)
}
}
const addCanvasWatermark = (canvas: HTMLCanvasElement, options: WatermarkOptions) => {
return canvasGenerator.addWatermark(canvas, options)
}
return {
generatePDFWithWatermark,
addCanvasWatermark
}
}使用示例:
vue
<template>
<div>
<div ref="contentRef" class="document-content">
<h1>机密报告</h1>
<p>这是一份机密文档...</p>
</div>
<el-button @click="exportWithWatermark" type="primary">
导出带水印PDF
</el-button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { usePDFWatermark } from '@/composables/usePDFWatermark'
const contentRef = ref<HTMLElement>()
const { generatePDFWithWatermark } = usePDFWatermark()
const exportWithWatermark = async () => {
if (!contentRef.value) return
await generatePDFWithWatermark({
element: contentRef.value,
filename: '机密报告.pdf',
watermark: {
text: '内部机密',
fontSize: 36,
color: '#ff0000',
opacity: 0.15,
angle: -30,
position: 'tile',
gap: 180
},
pageSize: 'A4',
orientation: 'portrait'
})
}
</script>