const handlePrint = () => {
window.print()
}
@media print {
/* 隐藏不需要打印的元素,如导航栏、侧边栏、按钮 */
.no-print {
display: none !important;
}
/* 调整打印区域的宽度 */
.print-container {
width: 100%;
margin: 0;
padding: 0;
}
/* 强制分页 */
.page-break {
page-break-after: always;
}
}
import html2canvas from 'html2canvas-pro' // 推荐使用 pro 版本无缝替代
import jsPDF from 'jspdf'
/**
* 将指定 DOM 导出为 PDF
* @param domId 目标 DOM 元素的 ID
* @param title 导出的文件名
*/
export const exportPdf = async (domId: string, title?: string): Promise<void> => {
const ele = document.getElementById(domId)
if (!ele) throw new Error('未找到目标元素')
const scale = window.devicePixelRatio > 1 ? window.devicePixelRatio : 2
// 获取所有防截断元素(防止元素被分页切开,如表格行、标题、段落等)
const nodes = ele.querySelectorAll('tr, h2, h3, h4, h5, p, img')
const containerRect = ele.getBoundingClientRect()
// 同时收集元素的 top 和 bottom 坐标
const breakPointsPx = Array.from(nodes).map((node) => {
const rect = node.getBoundingClientRect()
return {
top: rect.top - containerRect.top,
bottom: rect.bottom - containerRect.top,
}
})
// 生成画布
const canvas = await html2canvas(ele, {
scale,
useCORS: true, // 允许图片跨域
backgroundColor: '#ffffff',
})
const imgDataUrl = canvas.toDataURL('image/jpeg', 1.0)
// 初始化 PDF 对象:p-竖向,pt-点(单位),a4-纸张规格
const pdf = new jsPDF('p', 'pt', 'a4')
const a4Width = pdf.internal.pageSize.getWidth()
const a4Height = pdf.internal.pageSize.getHeight()
// 计算图片缩放比例:根据宽度适配 A4
const ratio = a4Width / canvas.width
const imgWidth = a4Width
const imgHeight = canvas.height * ratio
// 将坐标单位从 px 转换为 pt (符合 PDF 内部计算)
const breakPointsPt = breakPointsPx.map((bp) => ({
top: bp.top * ratio,
bottom: bp.bottom * ratio,
}))
const topMargin = 30 // 页眉预留
const bottomMargin = 30 // 页脚预留
const pageContentHeight = a4Height - topMargin - bottomMargin
let currentRenderY = 0 // 已完成渲染的 Y 轴偏移
while (currentRenderY < imgHeight) {
let expectedPageBottom = currentRenderY + pageContentHeight
let actualPageBottom = expectedPageBottom
// 判断是不是最后一页
if (expectedPageBottom >= imgHeight) {
actualPageBottom = imgHeight
} else {
// 只有不是最后一页,才去遍历判断是否被截断
for (let i = 0; i < breakPointsPt.length; i++) {
const { top, bottom } = breakPointsPt
// 核心判断:元素的头在当前页,但尾巴超出了当前页的底部,说明被“腰斩”了
if (top > currentRenderY && top < expectedPageBottom && bottom > expectedPageBottom) {
actualPageBottom = top // 在被截断元素的顶部切一刀,将其整体推到下一页
break
}
}
}
if (actualPageBottom === currentRenderY) actualPageBottom = expectedPageBottom
// 1. 渲染当前页图像(利用负偏移显示指定区域)
pdf.addImage(imgDataUrl, 'JPEG', 0, topMargin - currentRenderY, imgWidth, imgHeight)
// 2. 顶部遮罩(覆盖负偏移区域产生的重叠部分)
if (currentRenderY > 0) {
pdf.setFillColor(255, 255, 255)
pdf.rect(0, 0, a4Width, topMargin, 'F')
}
// 3. 底部遮罩(留白并遮挡截断处的残影)
const currentRenderBottomY = topMargin + (actualPageBottom - currentRenderY)
pdf.setFillColor(255, 255, 255)
pdf.rect(0, currentRenderBottomY, a4Width, a4Height - currentRenderBottomY, 'F')
currentRenderY = actualPageBottom
// 如果还没画完,添加新的一页
if (currentRenderY + 5 < imgHeight) {
pdf.addPage()
}
}
const fileName = title ? `${title}_${Date.now()}` : Date.now().toString()
pdf.save(`${fileName}.pdf`)
}
import { exportPdf } from './utils/pdf'
const ReportPage = () => {
const handleDownload = async () => {
try {
// 传入容器 ID 和文件名
await exportPdf('pdf-content', '月度分析报告')
} catch (error) {
console.error('生成 PDF 失败:', error)
}
}
return (
<div>
<button onClick={handleDownload}>下载报告</button>
{/* 这里的 ID 必须与 exportPdf 传入的一致 */}
<div id="pdf-content" style={{ padding: '20px', background: '#fff' }}>
<h2>报表标题</h2>
<p>这里是很长很长的内容,可能会跨页...</p>
<table>
<tbody>
<tr>
<td>数据行 1</td>
</tr>
{/* 这里的 tr 会被防截断逻辑自动推送到下一页容器中 */}
<tr>
<td>数据行 2</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
src/
├── components/
│ └── pdf-templates/ # 所有的 PDF UI 模板
│ ├── Contract.tsx # 合同模板
│ ├── Invoice.tsx # 发票模板
│ └── index.ts # 统一导出
└── utils/
└── pdf.ts # 核心 exportPdf 方法
// src/components/pdf-templates/ContractTemplate.tsx
interface IProps {
data: any
}
export const ContractTemplate = ({ data }: IProps) => (
<div id="pdf-render-target" style={{ width: '800px', padding: '40px' }}>
<h1>{data.title}</h1>
{/* 自由编写复杂的 PDF 样式 */}
</div>
)
// src/pages/OrderDetails.tsx
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { exportPdf } from '../utils/pdf'
import { ContractTemplate } from '../components/pdf-templates'
const OrderDetails = () => {
const [isExporting, setIsExporting] = useState(false)
const [data, setData] = useState(null)
const startExport = async () => {
setIsExporting(true)
// 1. 获取业务数据 (如从 API 获取)
const res = await fetchOrderData()
setData(res)
// 2. 等待 React 渲染 DOM (利用 setTimeout 确保渲染完成)
setTimeout(async () => {
try {
await exportPdf('pdf-render-target', '业务合同')
} finally {
setIsExporting(false)
}
}, 100)
}
return (
<div>
<button onClick={startExport} disabled={isExporting}>
{isExporting ? '正在生成...' : '下载 PDF'}
</button>
{/* 通过 Portal 将模板渲染在屏幕外,实现“无感”生成 */}
{isExporting &&
data &&
createPortal(
<div style={{ position: 'absolute', left: '-9999px', top: 0 }}>
<ContractTemplate data={data} />
</div>,
document.body
)}
</div>
)
}