一个前端H5实现用户签名并生成包含该签名的PDF发票的功能。
这个任务涉及几个关键技术点:
- HTML5 Canvas: 用于绘制用户签名。我们需要捕捉用户的鼠标或触摸事件,并在Canvas上绘制路径。
- JavaScript: 处理用户交互(绘图、清除、生成PDF)、操作Canvas API、处理表单数据。
- CSS: 美化界面,定义Canvas区域样式。
- PDF生成库: 在前端生成PDF。常用的库有
jsPDF和html2canvas。html2canvas可以将HTML元素渲染成Canvas图像,然后jsPDF可以将这个图像或其他内容添加到PDF文档中。这种组合对于将包含复杂布局(如发票)和签名图像的HTML结构转换为PDF非常有用。
项目结构:
/signature-invoice-app
|-- index.html # HTML 结构
|-- style.css # CSS 样式
|-- script.js # JavaScript 逻辑
|-- /libs # (可选) 存放本地库文件
|-- jspdf.umd.min.js
|-- html2canvas.min.js
我们将使用CDN链接来引入 jsPDF 和 html2canvas,这样更简单。如果需要离线使用,可以下载库文件放到 libs 目录并在HTML中引用本地路径。
1. HTML (index.html)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H5 签名与 PDF 发票生成</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
</head>
<body>
<h1>电子发票签名与生成</h1>
<div id="invoice-form" class="invoice-section">
<h2>发票信息</h2>
<div class="form-group">
<label for="invoice-number">发票号码:</label>
<input type="text" id="invoice-number" value="INV-20250503-001">
</div>
<div class="form-group">
<label for="customer-name">客户名称:</label>
<input type="text" id="customer-name" value="张三">
</div>
<div class="form-group">
<label for="invoice-date">开票日期:</label>
<input type="date" id="invoice-date">
</div>
<div class="form-group">
<label for="item-description">项目名称:</label>
<input type="text" id="item-description" value="技术咨询服务">
</div>
<div class="form-group">
<label for="item-quantity">数量:</label>
<input type="number" id="item-quantity" value="1">
</div>
<div class="form-group">
<label for="item-price">单价 (元):</label>
<input type="number" id="item-price" step="0.01" value="500.00">
</div>
<div class="form-group">
<label for="total-amount">总金额 (元):</label>
<input type="text" id="total-amount" readonly> </div>
<div class="form-group">
<label for="company-name">销售方名称:</label>
<input type="text" id="company-name" value="示例科技有限公司">
</div>
<div class="form-group">
<label for="company-tax-id">销售方税号:</label>
<input type="text" id="company-tax-id" value="91310000MA1FL12345">
</div>
<div class="form-group">
<label for="company-address">销售方地址:</label>
<input type="text" id="company-address" value="XX市XX区XX路123号">
</div>
<div class="form-group">
<label for="company-phone">销售方电话:</label>
<input type="text" id="company-phone" value="010-12345678">
</div>
</div>
<div id="signature-section" class="invoice-section">
<h2>客户签名确认</h2>
<div class="canvas-container">
<canvas id="signature-pad" width="400" height="200"></canvas>
<p class="signature-placeholder">请在此处签名</p>
</div>
<div class="signature-controls">
<button id="clear-button">清除签名</button>
<button id="undo-button">撤销上一步</button> </div>
</div>
<div class="action-buttons">
<button id="generate-pdf-button">生成并预览 PDF 发票</button>
</div>
<div id="printable-invoice" style="position: absolute; left: -9999px; top: auto; width: 800px; padding: 20px; border: 1px solid #ccc; background-color: white; font-family: 'SimSun', serif;">
<h2 style="text-align: center; margin-bottom: 20px;">电子发票</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>发票号码:</strong></td>
<td style="border: 1px solid #ddd; padding: 8px;" id="pdf-invoice-number"></td>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>开票日期:</strong></td>
<td style="border: 1px solid #ddd; padding: 8px;" id="pdf-invoice-date"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>客户名称:</strong></td>
<td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-customer-name"></td>
</tr>
</table>
<h3 style="margin-top: 20px; border-bottom: 1px solid #eee; padding-bottom: 5px;">货物或应税劳务、服务名称</h3>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">项目名称</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">数量</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">单价 (元)</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: right;">金额 (元)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;" id="pdf-item-description"></td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-item-quantity"></td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-item-price"></td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-item-total"></td>
</tr>
</tbody>
</table>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>价税合计 (大写):</strong></td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-total-amount-chinese" colspan="3"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>价税合计 (小写):</strong></td>
<td style="border: 1px solid #ddd; padding: 8px; text-align: right;" id="pdf-total-amount" colspan="3"></td>
</tr>
</table>
<h3 style="margin-top: 20px; border-bottom: 1px solid #eee; padding-bottom: 5px;">销售方信息</h3>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 30px;">
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>名称:</strong></td>
<td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-company-name"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>纳税人识别号:</strong></td>
<td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-company-tax-id"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><strong>地址、电话:</strong></td>
<td style="border: 1px solid #ddd; padding: 8px;" colspan="3" id="pdf-company-address-phone"></td>
</tr>
</table>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px dashed #ccc;">
<h3 style="margin-bottom: 10px;">客户签名确认:</h3>
<div id="pdf-signature-area" style="width: 300px; height: 150px; border: 1px solid #eee; background-color: #f9f9f9;">
<img id="pdf-signature-image" src="" alt="客户签名" style="display: block; max-width: 100%; max-height: 100%;">
</div>
</div>
<p style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
打印日期: <span id="pdf-print-date"></span>
</p>
</div>
<script src="script.js"></script>
</body>
</html>
2. CSS (style.css)
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
margin: 20px;
background-color: #f4f7f6;
color: #333;
}
h1, h2, h3 {
color: #2c3e50;
margin-bottom: 1em;
}
h1 {
text-align: center;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
margin-bottom: 30px;
}
.invoice-section {
background-color: #ffffff;
padding: 25px;
margin-bottom: 25px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 15px;
display: flex; /* 使用flex布局让label和input对齐 */
align-items: center; /* 垂直居中对齐 */
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #555;
min-width: 120px; /* 固定标签宽度 */
text-align: right; /* 标签右对齐 */
margin-right: 10px; /* 标签和输入框间距 */
}
.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="number"] {
flex-grow: 1; /* 输入框占据剩余空间 */
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box; /* 内边距和边框包含在宽度内 */
font-size: 1rem;
}
.form-group input[readonly] {
background-color: #eee;
cursor: not-allowed;
}
/* 签名区域特定样式 */
#signature-section h2 {
margin-bottom: 15px;
}
.canvas-container {
position: relative; /* 为了占位符的绝对定位 */
width: 400px; /* 显式设置容器宽度 */
height: 200px; /* 显式设置容器高度 */
margin-bottom: 10px; /* 与下方按钮的间距 */
}
#signature-pad {
border: 2px dashed #3498db;
background-color: #f0f8ff; /* 淡蓝色背景 */
cursor: crosshair;
display: block; /* 消除可能的下方空白 */
box-sizing: border-box; /* 边框计入宽高 */
width: 100%; /* 填充容器 */
height: 100%; /* 填充容器 */
}
.signature-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #aaa;
pointer-events: none; /* 不干扰鼠标事件 */
font-style: italic;
transition: opacity 0.3s ease; /* 添加淡出效果 */
z-index: 1; /* 在 Canvas 上方,但会被绘制覆盖 */
}
/* 如果 Canvas 上有内容,隐藏占位符 */
.canvas-container.has-content .signature-placeholder {
opacity: 0;
}
.signature-controls {
margin-top: 10px;
display: flex; /* 横向排列按钮 */
gap: 10px; /* 按钮间距 */
}
.action-buttons {
text-align: center; /* 居中主操作按钮 */
margin-top: 20px;
}
button {
padding: 12px 25px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
border: none;
border-radius: 5px;
transition: background-color 0.3s ease, transform 0.1s ease;
background-color: #3498db;
color: white;
}
button:hover {
background-color: #2980b9;
}
button:active {
transform: translateY(1px); /* 点击时轻微下沉 */
}
#clear-button, #undo-button {
background-color: #e74c3c;
}
#clear-button:hover, #undo-button:hover {
background-color: #c0392b;
}
#generate-pdf-button {
background-color: #2ecc71;
}
#generate-pdf-button:hover {
background-color: #27ae60;
}
/* 响应式调整 */
@media (max-width: 768px) {
.form-group {
flex-direction: column; /* 在小屏幕上垂直排列 */
align-items: flex-start; /* 左对齐 */
}
.form-group label {
min-width: auto; /* 取消最小宽度 */
text-align: left; /* 左对齐 */
margin-right: 0;
margin-bottom: 5px; /* 标签和输入框间距 */
}
.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="number"] {
width: 100%; /* 输入框占满宽度 */
}
.canvas-container {
width: 100%; /* 画布容器适应屏幕 */
max-width: 400px; /* 但不超过最大宽度 */
height: auto; /* 高度自适应 */
aspect-ratio: 2 / 1; /* 保持 2:1 的宽高比 */
}
#signature-pad {
/* 宽高由容器决定 */
}
}
/* 打印相关的隐藏div,确保其内容在html2canvas中正常渲染 */
#printable-invoice {
font-size: 14px; /* 为PDF设置合适的字体大小 */
line-height: 1.5;
}
#printable-invoice h2, #printable-invoice h3 {
color: #000; /* PDF中通常用黑色 */
}
#printable-invoice table {
font-size: 12px; /* 表格内字体稍小 */
}
#printable-invoice td, #printable-invoice th {
border: 1px solid #333 !important; /* 确保边框在PDF中清晰 */
padding: 6px !important;
}
#printable-invoice th {
background-color: #f2f2f2; /* 表头背景色 */
text-align: center;
}
3. JavaScript (script.js)
// 确保在 DOM 加载完成后执行脚本
window.addEventListener('DOMContentLoaded', (event) => {
console.log('DOM fully loaded and parsed');
// --- Canvas 签名相关变量 ---
const canvas = document.getElementById('signature-pad');
const ctx = canvas.getContext('2d');
const clearButton = document.getElementById('clear-button');
const undoButton = document.getElementById('undo-button'); // 撤销按钮
const placeholder = document.querySelector('.signature-placeholder');
const canvasContainer = document.querySelector('.canvas-container');
let isDrawing = false;
let lastX = 0;
let lastY = 0;
let history = []; // 用于存储绘制步骤,实现撤销功能
// --- 发票表单元素 ---
const invoiceNumberInput = document.getElementById('invoice-number');
const customerNameInput = document.getElementById('customer-name');
const invoiceDateInput = document.getElementById('invoice-date');
const itemDescriptionInput = document.getElementById('item-description');
const itemQuantityInput = document.getElementById('item-quantity');
const itemPriceInput = document.getElementById('item-price');
const totalAmountInput = document.getElementById('total-amount');
const companyNameInput = document.getElementById('company-name');
const companyTaxIdInput = document.getElementById('company-tax-id');
const companyAddressInput = document.getElementById('company-address');
const companyPhoneInput = document.getElementById('company-phone');
// --- PDF 生成按钮 ---
const generatePdfButton = document.getElementById('generate-pdf-button');
// --- PDF 模板中的元素 ---
const pdfInvoiceNumber = document.getElementById('pdf-invoice-number');
const pdfInvoiceDate = document.getElementById('pdf-invoice-date');
const pdfCustomerName = document.getElementById('pdf-customer-name');
const pdfItemDescription = document.getElementById('pdf-item-description');
const pdfItemQuantity = document.getElementById('pdf-item-quantity');
const pdfItemPrice = document.getElementById('pdf-item-price');
const pdfItemTotal = document.getElementById('pdf-item-total');
const pdfTotalAmount = document.getElementById('pdf-total-amount');
const pdfTotalAmountChinese = document.getElementById('pdf-total-amount-chinese');
const pdfCompanyName = document.getElementById('pdf-company-name');
const pdfCompanyTaxId = document.getElementById('pdf-company-tax-id');
const pdfCompanyAddressPhone = document.getElementById('pdf-company-address-phone');
const pdfSignatureImage = document.getElementById('pdf-signature-image');
const pdfPrintDate = document.getElementById('pdf-print-date');
// --- 初始化 ---
// 设置 Canvas 绘图上下文的样式
function setupCanvasContext() {
ctx.strokeStyle = '#000000'; // 笔触颜色:黑色
ctx.lineWidth = 2; // 笔触宽度
ctx.lineCap = 'round'; // 线条末端样式:圆形
ctx.lineJoin = 'round'; // 线条连接处样式:圆形
}
// 调整 Canvas 尺寸以适应高 DPI 屏幕,防止模糊
function resizeCanvas() {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
// 这里我们从CSS获取画布的显示尺寸
const cssWidth = canvas.clientWidth;
const cssHeight = canvas.clientHeight;
// 设置画布的实际绘图缓冲区大小
canvas.width = cssWidth * ratio;
canvas.height = cssHeight * ratio;
// 使用 scale 方法放大上下文,使得绘图坐标与CSS像素匹配
ctx.scale(ratio, ratio);
console.log(`Canvas resized: ${canvas.width}x${canvas.height} (CSS: ${cssWidth}x${cssHeight}), Ratio: ${ratio}`);
// 重新设置绘图样式,因为尺寸变化可能重置上下文状态
setupCanvasContext();
// 重绘历史记录
redrawHistory();
}
// 保存当前画布状态到历史记录
function saveHistory() {
// 优化:仅在画布内容实际发生变化时保存
// 这里简单起见,每次绘制结束都保存,但实际应用中可以优化
if (ctx.getImageData(0, 0, canvas.width, canvas.height).data.some(channel => channel !== 0)) {
// 将当前画布内容转换为图像数据URL,并存入历史数组
history.push(canvas.toDataURL());
console.log(`History saved. Length: ${history.length}`);
}
}
// 从历史记录中恢复上一步状态
function restoreHistory() {
if (history.length > 1) { // 至少需要有初始状态和一步操作
// 移除最后一步
history.pop();
console.log(`History popped. Length: ${history.length}`);
// 获取上一步的图像数据
const previousImageDataUrl = history[history.length - 1];
// 创建一个图像对象来加载这个数据URL
const img = new Image();
img.onload = () => {
// 清除当前画布
clearCanvasInternal(false); // 不记录清除操作本身
// 将上一步的图像绘制回画布
// 注意:因为我们调整了 context 的 scale,所以直接画图即可
ctx.drawImage(img, 0, 0, canvas.clientWidth, canvas.clientHeight);
checkCanvasContent(); // 检查恢复后画布是否有内容
};
img.src = previousImageDataUrl;
} else if (history.length === 1) {
// 如果只剩下初始的空状态,则直接清空画布
clearCanvasInternal(false); // 不记录清除操作本身
history.pop(); // 清空历史
checkCanvasContent();
console.log('History restored to empty state.');
} else {
console.log('No history to restore.');
}
}
// 重绘历史记录(用于resize等情况)
function redrawHistory() {
if (history.length > 0) {
const lastImageDataUrl = history[history.length - 1];
const img = new Image();
img.onload = () => {
// 清除当前画布
clearCanvasInternal(false);
// 绘制最后的状态
ctx.drawImage(img, 0, 0, canvas.clientWidth, canvas.clientHeight);
checkCanvasContent(); // 检查恢复后画布是否有内容
};
img.src = lastImageDataUrl;
} else {
clearCanvasInternal(false); // 如果没历史,确保画布是干净的
}
}
// 清除 Canvas 内容 (内部方法,可选是否保存历史)
function clearCanvasInternal(save = true) {
// 清除指定矩形区域的内容
// 由于设置了 scale,这里的宽高应该是画布的CSS宽高
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
console.log('Canvas cleared.');
if (save) {
// 保存空白状态到历史
history = []; // 清除历史记录
history.push(canvas.toDataURL()); // 保存空白状态
console.log('History reset with empty state.');
}
checkCanvasContent(); // 检查画布内容状态
}
// 公开的清除画布方法(供按钮调用)
function clearCanvas() {
clearCanvasInternal(true);
}
// 检查画布是否有内容,并相应地更新样式(例如隐藏占位符)
function checkCanvasContent() {
// 稍微复杂一点的检查:获取画布像素数据,检查是否有非透明/非背景色像素
// 一个简化的方法是假设只要历史记录大于1(意味着有绘制操作)就有内容
// 或者直接比较当前画布与空白画布的 DataURL (可能效率不高)
// 这里我们采用一个更可靠的方式:检查是否有非透明像素
let hasContent = false;
try {
// getImageData 可能会因为画布被污染(tainted)而报错,但在此场景下不太可能
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 检查 Alpha 通道,只要有一个像素不是完全透明,就认为有内容
// 注意,这里检查的是乘以 D P I 后的画布缓冲区
for (let i = 3; i < imageData.data.length; i += 4) { // i=3 是第一个像素的 Alpha通道
if (imageData.data[i] !== 0) {
hasContent = true;
break;
}
}
} catch (e) {
console.error("Error checking canvas content:", e);
// 出错时保守地认为可能有内容,或者根据历史记录判断
hasContent = history.length > 1;
}
if (hasContent) {
canvasContainer.classList.add('has-content');
// placeholder.style.display = 'none'; // 可以用CSS的opacity代替
} else {
canvasContainer.classList.remove('has-content');
// placeholder.style.display = 'block'; // 可以用CSS的opacity代替
}
console.log(`Canvas has content: ${hasContent}`);
}
// --- 绘图事件处理 ---
// 获取鼠标或触摸在 Canvas 上的相对坐标
function getCoordinates(event) {
event.preventDefault(); // 阻止默认行为,如页面滚动(对触摸事件尤其重要)
const rect = canvas.getBoundingClientRect();
let x, y;
if (event.touches && event.touches.length > 0) {
// 触摸事件
x = event.touches[0].clientX - rect.left;
y = event.touches[0].clientY - rect.top;
} else {
// 鼠标事件
x = event.clientX - rect.left;
y = event.clientY - rect.top;
}
// console.log(`Coords: x=${x.toFixed(2)}, y=${y.toFixed(2)}`);
return { x, y };
}
// 开始绘制(鼠标按下或触摸开始)
function startDrawing(event) {
isDrawing = true;
const coords = getCoordinates(event);
[lastX, lastY] = [coords.x, coords.y];
// console.log('Start drawing at:', lastX, lastY);
// 开始新路径,避免连接到上一次绘制的结束点
ctx.beginPath();
ctx.moveTo(lastX, lastY); // 将画笔移动到起点
checkCanvasContent(); // 检查内容状态,隐藏占位符
}
// 绘制过程(鼠标移动或触摸移动)
function draw(event) {
if (!isDrawing) return; // 如果不是正在绘制状态,则不执行
const coords = getCoordinates(event);
// console.log('Drawing to:', coords.x, coords.y);
// 从上一个点绘制一条线到当前点
ctx.lineTo(coords.x, coords.y);
ctx.stroke(); // 执行绘制
// 更新上一个点的位置
[lastX, lastY] = [coords.x, coords.y];
}
// 停止绘制(鼠标松开、触摸结束、鼠标离开画布)
function stopDrawing() {
if (!isDrawing) return;
isDrawing = false;
// console.log('Stop drawing.');
// 可以在这里添加优化:如果绘制的路径很短或没有实际绘制,则不保存历史
saveHistory(); // 保存这次绘制的结果
checkCanvasContent(); // 再次检查内容状态
}
// --- 发票金额计算 ---
function calculateTotal() {
const quantity = parseFloat(itemQuantityInput.value) || 0;
const price = parseFloat(itemPriceInput.value) || 0;
const total = (quantity * price).toFixed(2); // 保留两位小数
totalAmountInput.value = total;
}
// 数字金额转中文大写
function amountToChinese(n) {
if (n === null || n === undefined || isNaN(parseFloat(n))) {
return "零元整";
}
n = parseFloat(n); // 确保是数字
if (Math.abs(n) >= 1000000000000) {
return "金额过大,无法转换";
}
var fraction = ['角', '分'];
var digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
var unit = [['元', '万', '亿'], ['', '拾', '佰', '仟']];
var head = n < 0 ? '负' : '';
n = Math.abs(n);
var s = '';
// 处理小数部分
var小数部分 = "";
var rawFraction = ((n * 100) % 100).toFixed(0); // 取小数后两位并转为整数
if (rawFraction === "0") {
小数部分 = '整';
} else {
let jiao = Math.floor(rawFraction / 10);
let fen = rawFraction % 10;
if (jiao > 0) {
小数部分 += digit[jiao] + fraction[0];
} else if (fen > 0) {
// 如果角为0,分不为0,需要加“零”
小数部分 += '零';
}
if (fen > 0) {
小数部分 += digit[fen] + fraction[1];
}
}
n = Math.floor(n); // 取整数部分
if (n === 0 && 小数部分 !== '整') {
s = 小数部分; // 如果只有小数部分,则直接返回,例如 零角伍分
} else if (n === 0 && 小数部分 === '整') {
return "零元整"; // 处理 0.00 的情况
}
else {
// 处理整数部分
for (var i = 0; i < unit[0].length && n > 0; i++) {
var p = '';
for (var j = 0; j < unit[1].length && n > 0; j++) {
p = digit[n % 10] + unit[1][j] + p;
n = Math.floor(n / 10);
}
// 优化零的显示:
p = p.replace(/零(拾|佰|仟)/g, '零'); // 零拾、零佰、零仟 -> 零
p = p.replace(/零+/g, '零'); // 多个零 -> 一个零
p = p.replace(/零(万|亿|元)/g, '$1'); // 零万 -> 万, 零亿 -> 亿, 零元 -> 元
p = p.replace(/^元/, ''); // 如果以元开头(整数部分为0),去掉元
p = p.replace(/亿万/g, '亿'); // 亿万 -> 亿
s = p + unit[0][i] + s;
}
// 再次清理零和元
s = s.replace(/零(万|亿|元)/g, '$1');
s = s.replace(/零+/g, '零'); // 清理可能产生的连续零
s = s.replace(/零$/, ''); // 去掉末尾的零(在元之前)
// 确保整数部分不为空时,有“元”字
if (!s.includes('元') && n === 0 && s !== '') {
// 如果整数部分处理后为空(例如 0.5 元的情况),需要加上元
// 但如果原数是 10 元,s 会是 "壹拾",此时也需要加元
// 检查 s 是否以 '角' 或 '分' 结尾
if (!/(角|分)$/.test(小数部分)) { // 如果小数部分不是 "整"
s += '元';
}
} else if (!s.includes('元') && s.length > 0 && !/(角|分)$/.test(小数部分)){
// 处理 15 元 这种情况, s = "壹拾伍",需要加元
s += '元';
}
s = head + s + 小数部分; // 组合整数和小数部分
s = s.replace(/^元/, '零元'); // 如果结果是 "元整" 或 "元X角X分", 加上 "零元"
s = s.replace(/零元整$/, '零元整'); // 防止出现 零元整整
}
// 最终清理
s = s.replace(/零(角|分)/g, '零'); // 零角/零分 -> 零
s = s.replace(/零+/g, '零'); // 再清理一次零
s = s.replace(/零(元|万|亿)/g, '$1');
s = s.replace(/亿万/g, '亿');
if (s.endsWith('零') && !s.endsWith('零元整') && s !== '零元整') { // 去掉非 "零元整" 结尾的 "零"
s = s.slice(0, -1);
}
if (s === '元整' || s === '元'){ // 处理特殊情况 1.00
s = '壹元整';
}
return s;
}
// --- PDF 生成逻辑 ---
async function generatePDF() {
console.log('Starting PDF generation...');
// 1. 检查签名是否存在
const signatureDataUrl = canvas.toDataURL('image/png'); // 获取签名图像
const blankCanvasDataUrl = document.createElement('canvas').toDataURL('image/png'); // 获取一个空白 Canvas 的 DataURL 用于比较
// 更可靠的检查空画布方法
let isCanvasBlank = true;
try {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 3; i < imageData.data.length; i += 4) {
if (imageData.data[i] !== 0) { // 检查 alpha 通道
isCanvasBlank = false;
break;
}
}
} catch(e) {
console.warn("Could not accurately check if canvas is blank, assuming not blank if history exists.", e);
isCanvasBlank = history.length <= 1; // 回退到基于历史记录的判断
}
if (isCanvasBlank) {
alert('请先在指定区域完成签名!');
console.warn('PDF generation stopped: Signature is missing.');
return; // 终止执行
}
console.log('Signature found.');
// 2. 获取表单数据并填充到隐藏的 PDF 模板区域
console.log('Populating PDF template data...');
const currentDate = new Date();
const formattedDate = `${currentDate.getFullYear()}-${(currentDate.getMonth() + 1).toString().padStart(2, '0')}-${currentDate.getDate().toString().padStart(2, '0')}`;
const formattedTime = `${currentDate.getHours().toString().padStart(2, '0')}:${currentDate.getMinutes().toString().padStart(2, '0')}:${currentDate.getSeconds().toString().padStart(2, '0')}`;
// 填充文本信息
pdfInvoiceNumber.textContent = invoiceNumberInput.value;
pdfInvoiceDate.textContent = invoiceDateInput.value || formattedDate; // 如果没选日期用当前日期
pdfCustomerName.textContent = customerNameInput.value;
pdfItemDescription.textContent = itemDescriptionInput.value;
pdfItemQuantity.textContent = itemQuantityInput.value;
const price = parseFloat(itemPriceInput.value).toFixed(2);
pdfItemPrice.textContent = price;
const total = (parseFloat(itemQuantityInput.value) * parseFloat(price)).toFixed(2);
pdfItemTotal.textContent = total;
pdfTotalAmount.textContent = `¥${total}`;
pdfTotalAmountChinese.textContent = amountToChinese(total);
pdfCompanyName.textContent = companyNameInput.value;
pdfCompanyTaxId.textContent = companyTaxIdInput.value;
pdfCompanyAddressPhone.textContent = `${companyAddressInput.value} ${companyPhoneInput.value}`;
pdfPrintDate.textContent = `${formattedDate} ${formattedTime}`;
// 填充签名图片
pdfSignatureImage.src = signatureDataUrl;
pdfSignatureImage.style.display = 'block'; // 确保图片可见
console.log('PDF template populated.');
// 3. 使用 html2canvas 将填充好的模板区域转换为 Canvas
const printableElement = document.getElementById('printable-invoice');
console.log('Running html2canvas on element:', printableElement);
try {
const generatedCanvas = await html2canvas(printableElement, {
scale: 2, // 提高截图分辨率,使 PDF 更清晰
useCORS: true, // 如果包含跨域图片需要这个,这里签名是 DataURL 不需要
logging: true, // 开启日志方便调试
backgroundColor: '#ffffff' // 显式设置背景色,防止透明
});
console.log('html2canvas generated canvas successfully.');
// 4. 将 html2canvas 生成的 Canvas 转换为图像数据
const imgData = generatedCanvas.toDataURL('image/jpeg', 0.9); // 使用JPEG减少文件大小,质量90%
console.log('Converted generated canvas to JPEG data URL.');
// 5. 使用 jsPDF 创建 PDF 文档并添加图像
// 引入jsPDF (确保已在HTML中通过<script>引入)
const { jsPDF } = window.jspdf;
// 创建 jsPDF 实例 (默认 A4 纸张, 纵向)
// 单位可以是 'pt', 'mm', 'cm', 'in',A4 尺寸约为 210mm x 297mm
const pdf = new jsPDF('p', 'mm', 'a4');
console.log('jsPDF instance created.');
const pdfWidth = pdf.internal.pageSize.getWidth(); // A4 宽度 mm
const pdfHeight = pdf.internal.pageSize.getHeight(); // A4 高度 mm
// 计算图像在 PDF 中的尺寸,保持宽高比
const imgProps = pdf.getImageProperties(imgData); // 获取图像原始尺寸 (px)
const imgWidth = imgProps.width;
const imgHeight = imgProps.height;
console.log(`Image properties: width=${imgWidth}px, height=${imgHeight}px`);
// 计算缩放比例,使得图像能适应 PDF 页面宽度,并留出一些边距
const margin = 10; // PDF 页面边距 (mm)
const availableWidth = pdfWidth - 2 * margin;
const scale = availableWidth / imgWidth; // 以宽度为基准进行缩放
const scaledHeight = imgHeight * scale;
// 检查缩放后的高度是否超过页面高度
let finalHeight = scaledHeight;
let finalWidth = availableWidth;
let yPosition = margin; // 图片在 PDF 中的起始 Y 坐标
if (scaledHeight > (pdfHeight - 2 * margin)) {
console.warn("Scaled image height exceeds PDF page height. Scaling based on height instead.");
// 如果高度超出,则改为基于高度进行缩放
const availableHeight = pdfHeight - 2 * margin;
const scaleHeight = availableHeight / imgHeight;
finalHeight = availableHeight;
finalWidth = imgWidth * scaleHeight;
} else {
// 垂直居中(可选)
// yPosition = (pdfHeight - scaledHeight) / 2;
}
console.log(`Calculated PDF image size: width=${finalWidth}mm, height=${finalHeight}mm`);
// 添加图像到 PDF
// addImage(imageData, format, x, y, width, height, alias, compression, rotation)
pdf.addImage(imgData, 'JPEG', margin, yPosition, finalWidth, finalHeight);
console.log('Image added to PDF.');
// 6. 保存或预览 PDF
// pdf.save('invoice-with-signature.pdf'); // 直接下载
// 在新窗口预览 PDF
pdf.output('dataurlnewwindow');
console.log('PDF generated and opened for preview.');
} catch (error) {
console.error('Error during PDF generation:', error);
alert('生成 PDF 时出错,请检查控制台获取详细信息。');
} finally {
// 可选:生成后隐藏签名图片,避免在模板区域一直显示
// pdfSignatureImage.style.display = 'none';
// pdfSignatureImage.src = '';
}
}
// --- 事件监听器绑定 ---
// 绘图事件 (鼠标)
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseleave', stopDrawing); // 鼠标移出画布也停止绘制
// 绘图事件 (触摸屏)
canvas.addEventListener('touchstart', startDrawing, { passive: false }); // passive: false 允许 preventDefault
canvas.addEventListener('touchmove', draw, { passive: false });
canvas.addEventListener('touchend', stopDrawing);
canvas.addEventListener('touchcancel', stopDrawing); // 触摸取消时也停止
// 清除按钮
clearButton.addEventListener('click', clearCanvas);
// 撤销按钮
undoButton.addEventListener('click', restoreHistory);
// 生成 PDF 按钮
generatePdfButton.addEventListener('click', generatePDF);
// 输入框变化时自动计算总价
itemQuantityInput.addEventListener('input', calculateTotal);
itemPriceInput.addEventListener('input', calculateTotal);
// --- 页面加载后的初始化操作 ---
console.log('Setting up initial state...');
// 设置 Canvas 初始尺寸和上下文
resizeCanvas(); // 初始调整 Canvas 尺寸
// 监听窗口大小变化,重新调整Canvas(可选,但推荐)
let resizeTimeout;
window.addEventListener('resize', () => {
// 使用防抖(debounce)避免频繁触发 resize
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
console.log('Window resized, resizing canvas...');
resizeCanvas();
}, 250); // 延迟 250ms 执行
});
// 初始化总金额
calculateTotal();
// 设置默认开票日期为今天
if (!invoiceDateInput.value) {
invoiceDateInput.valueAsDate = new Date();
}
// 初始检查画布状态(应该是空的)
checkCanvasContent();
// 保存初始空白状态到历史
clearCanvasInternal(true); // 确保 history[0] 是空白状态
console.log('Initialization complete. Application ready.');
});
代码讲解:
HTML (index.html)
-
<!DOCTYPE html>和<html>: 标准 HTML5 文档结构。 -
<head>:charset="UTF-8": 指定字符编码为 UTF-8,支持中文。viewport: 设置移动设备视口,width=device-width使宽度等于设备宽度,initial-scale=1.0设置初始缩放比例。title: 页面标题。link rel="stylesheet" href="style.css": 引入外部 CSS 文件。<script src="...">: 通过 CDN 引入jsPDF和html2canvas库。必须在使用它们之前引入。
-
<body>:-
<h1>: 主标题。 -
#invoice-form: 包含发票信息输入的div。<h2>: 表单区域标题。.form-group: 每个输入项的容器,使用 Flexbox 布局(见 CSS)使标签和输入框对齐。<label>和<input>: 标准的表单标签和输入字段。id用于 JavaScript 获取元素,for属性关联标签和输入框。input[type="date"]: 提供日期选择器。input[type="number"]: 用于数字输入,step="0.01"允许小数。input[readonly]: 只读字段,如自动计算的总金额。
-
#signature-section: 包含签名的div。.canvas-container: 包裹 Canvas 的容器,用于相对定位占位符和控制尺寸。<canvas id="signature-pad">: 核心的签名区域。width和height属性设置了 Canvas 的 绘图表面 的像素尺寸(注意这与 CSS 控制的 显示尺寸 不同)。JavaScript 中会根据设备像素比调整。.signature-placeholder: 一个<p>元素,用 CSS 绝对定位到 Canvas 中央,提示用户在此签名。当 Canvas 上有内容时会被隐藏(通过 JS 添加/移除.has-content类)。.signature-controls: 包含操作按钮(清除、撤销)的容器。<button id="clear-button">,<button id="undo-button">: 清除签名和撤销上一步绘制的按钮。
-
.action-buttons: 包含主要操作按钮(生成 PDF)的容器。<button id="generate-pdf-button">: 触发 PDF 生成过程的按钮。
-
#printable-invoice: 一个 非常重要 的div。- 它在页面上是 隐藏 的(通过 CSS
position: absolute; left: -9999px;),用户看不到它。 - 它的内部结构 完全模仿 了最终 PDF 发票的布局,使用了
<table>等元素来排版。 - 包含了所有发票信息的占位符
<span>或<td>(如id="pdf-invoice-number"),JavaScript 会在生成 PDF 前将表单数据填充到这里。 - 包含一个
<img>标签 (id="pdf-signature-image"),用于显示从 Canvas 获取到的签名图片。 - 关键作用:
html2canvas库会读取这个div的内容和样式,将其渲染成一个 Canvas 图像。这个图像随后会被jsPDF添加到 PDF 文件中。这避免了用jsPDF的 API 一点点绘制文本、线条和图像的复杂性,保证了 PDF 内容与 HTML 预览的高度一致性。 - 内部样式(
style="...")是为了确保在html2canvas渲染时有基本样式,即使外部 CSS 加载失败或被忽略。推荐使用更可靠的内联样式或确保 CSS 对此元素生效。设置了font-family: 'SimSun'(宋体) 以便在中文字符渲染上更像传统发票。
- 它在页面上是 隐藏 的(通过 CSS
-
<script src="script.js">: 在页面底部引入主要的 JavaScript 文件,确保执行时 HTML 元素已加载。
-
CSS (style.css)
-
body: 基本页面样式,字体、行高、边距、背景色。 -
h1, h2, h3: 标题样式。 -
.invoice-section: 为表单和签名区域提供统一的背景、内边距、圆角和阴影,使其看起来像卡片。 -
.form-group: 使用display: flex和align-items: center实现标签和输入框水平对齐。 -
label: 设置标签样式,min-width和text-align: right使其右对齐且宽度一致。 -
input: 输入框的基本样式,flex-grow: 1让其填充剩余空间。 -
input[readonly]: 设置只读输入框的样式。 -
#signature-section: 签名区域特定样式。 -
.canvas-container: 设置固定宽高(或响应式宽高),position: relative是为了.signature-placeholder的绝对定位。 -
#signature-pad:- 设置边框、背景色、光标样式。
display: block;避免canvas元素下方产生意外空白。width: 100%; height: 100%;使 Canvas 填充其容器.canvas-container的尺寸。
-
.signature-placeholder:- 使用绝对定位和
transform: translate(-50%, -50%);将其居中。 pointer-events: none;确保它不会捕获鼠标事件,让用户可以直接在 Canvas 上绘图。opacity和transition实现淡入淡出效果。
- 使用绝对定位和
-
.canvas-container.has-content .signature-placeholder: 当.canvas-container有has-content类时(由 JS 添加),隐藏占位符。 -
.signature-controls,.action-buttons: 按钮容器的布局。 -
button: 统一的按钮样式,包括悬停 (:hover) 和点击 (:active) 效果。 -
特定按钮样式 (
#clear-button,#generate-pdf-button) : 为不同功能的按钮设置不同的背景色。 -
@media (max-width: 768px): 响应式设计。在小屏幕设备上,将表单的标签和输入框改为垂直排列,画布容器宽度设为 100% 并保持宽高比。 -
#printable-invoice: 设置其基础字体大小、行高,并确保其内部元素的样式(如边框、字体颜色)在html2canvas渲染时是预期的,特别是在生成 PDF 时通常需要更清晰的黑色边框和文本。使用!important可能有助于覆盖其他冲突样式,但应谨慎使用。
JavaScript (script.js)
-
window.addEventListener('DOMContentLoaded', ...): 确保所有 HTML 元素都加载完毕后再执行 JavaScript 代码。 -
变量声明: 获取所有需要操作的 DOM 元素(Canvas、按钮、输入框、PDF 模板中的占位符)以及 Canvas 的 2D 绘图上下文 (
ctx)。声明绘图状态变量 (isDrawing,lastX,lastY) 和用于撤销功能的历史记录数组 (history)。 -
初始化 (
Initialization) :setupCanvasContext(): 设置 Canvas 绘图的默认样式(颜色、线宽、线帽样式)。resizeCanvas(): 关键函数。用于解决高 DPI 屏幕上 Canvas 绘图模糊的问题。它读取 Canvas 的 CSS 显示尺寸,然后根据设备的像素比 (window.devicePixelRatio) 放大 Canvas 的实际绘图缓冲区 (canvas.width,canvas.height)。之后,使用ctx.scale(ratio, ratio)缩放绘图上下文,这样后续绘图时使用的坐标仍然是基于 CSS 像素的,但实际绘制会更精细。每次调整尺寸后,需要重新设置绘图样式并重绘历史记录。saveHistory(): 在每次有效绘制操作结束后,将当前 Canvas 的内容保存为 Data URL 并添加到history数组。restoreHistory(): 实现撤销功能。从history数组中移除最后一次保存的状态,然后加载并绘制倒数第二个状态(即上一步的状态)到 Canvas 上。处理了历史为空或只有一个(初始空白)状态的情况。redrawHistory(): 当 Canvas 尺寸变化(如窗口 resize)后,需要根据历史记录中的最后状态重新绘制 Canvas 内容。clearCanvasInternal(): 内部使用的清除函数,可以通过参数控制是否将清除后的空白状态保存到历史记录。clearCanvas(): 公开给清除按钮调用的函数,会清除画布并重置历史记录(保存一个空白状态)。checkCanvasContent(): 检查 Canvas 是否真的有绘制内容(不仅仅是空白)。它通过getImageData获取像素数据,检查是否存在非完全透明的像素。根据检查结果,添加或移除.canvas-container的has-content类,从而控制占位符的显示/隐藏。
-
绘图事件处理 (
Drawing Event Handling) :getCoordinates(event): 获取事件(鼠标或触摸)相对于 Canvas 左上角的坐标。对触摸事件 (event.touches[0]) 和鼠标事件 (event.clientX,event.clientY) 分别处理,并减去 Canvas 的边界矩形 (getBoundingClientRect()) 的左上角坐标。调用event.preventDefault()阻止触摸事件可能引起的页面滚动。startDrawing(event): 在mousedown或touchstart时触发。设置isDrawing为true,记录起始坐标 (lastX,lastY),调用ctx.beginPath()开始新的绘图路径。draw(event): 在mousemove或touchmove时触发。如果isDrawing为true,获取当前坐标,使用ctx.lineTo()从上一个点画线到当前点,然后ctx.stroke()实际绘制线条。更新lastX,lastY。stopDrawing(): 在mouseup,touchend,mouseleave,touchcancel时触发。设置isDrawing为false。调用saveHistory()保存本次绘制操作。
-
发票金额计算 (
Invoice Amount Calculation) :calculateTotal(): 读取数量和单价输入框的值,计算总金额,并更新只读的总金额输入框。使用toFixed(2)保证两位小数。amountToChinese(n): 一个将阿拉伯数字金额转换为中文大写金额的函数。处理整数、小数、零、单位(元、角、分、拾、佰、仟、万、亿)等情况,并进行了一些格式优化。
-
PDF 生成逻辑 (
PDF Generation Logic - generatePDF()) :-
这是一个
async函数,因为html2canvas是异步的。 -
步骤 1: 检查签名: 使用
canvas.toDataURL()获取签名的图像数据。通过比较像素数据(checkCanvasContent的逻辑)判断 Canvas 是否为空。如果为空,弹出提示并返回。 -
步骤 2: 填充 PDF 模板: 获取所有发票输入框的值,并将它们填充到隐藏的
#printable-invoicediv 中对应的元素(textContent或src)。同时计算并填充总金额、大写金额、销售方组合地址电话、打印日期等。将获取到的签名signatureDataUrl设置为#pdf-signature-image的src。 -
步骤 3:
html2canvas渲染: 调用html2canvas(printableElement, options)。传入填充好数据的#printable-invoice元素和一些选项(如scale提高分辨率,backgroundColor避免透明)。await等待html2canvas完成渲染并返回一个新的 Canvas 对象 (generatedCanvas),这个 Canvas 对象包含了#printable-invoice的视觉快照。 -
步骤 4: 转换图像: 使用
generatedCanvas.toDataURL('image/jpeg', 0.9)将html2canvas生成的 Canvas 转换为 JPEG 格式的 Data URL。JPEG 通常比 PNG 文件小,适合嵌入 PDF。0.9是图像质量参数。 -
步骤 5:
jsPDF创建和添加图像:const { jsPDF } = window.jspdf;从全局window对象获取jsPDF构造函数(因为是通过 CDN 引入的)。const pdf = new jsPDF('p', 'mm', 'a4');创建一个新的 PDF 文档实例。'p'表示纵向 (portrait),'mm'表示单位为毫米,'a4'表示纸张大小为 A4。- 获取 PDF 页面的可用宽度和高度,并计算边距。
- 使用
pdf.getImageProperties(imgData)获取刚刚生成的 JPEG 图像的原始像素尺寸。 - 计算缩放比例,使图像宽度适应 PDF 页面宽度(减去边距),并按比例计算缩放后的高度。如果缩放后高度超出页面,则改为基于高度进行缩放。
- 调用
pdf.addImage(imgData, 'JPEG', margin, yPosition, finalWidth, finalHeight);将图像添加到 PDF 中。参数指定了图像数据、格式、在 PDF 页面上的 X/Y 坐标(带边距)、最终宽度和高度。
-
步骤 6: 输出 PDF:
pdf.output('dataurlnewwindow');在浏览器新标签页中打开生成的 PDF 进行预览。如果想直接触发下载,可以使用pdf.save('filename.pdf');。
-
错误处理: 使用
try...catch块捕获html2canvas或jsPDF可能抛出的错误,并在控制台打印错误信息,同时给用户一个提示。 -
finally块: 可以在这里添加一些清理操作(如果需要)。
-
-
事件监听器绑定 (
Event Listener Binding) :- 将
startDrawing,draw,stopDrawing函数绑定到 Canvas 的鼠标和触摸事件。注意passive: false用于触摸事件,允许在监听器内部调用preventDefault()。 - 将
clearCanvas,restoreHistory,generatePDF函数分别绑定到对应按钮的click事件。 - 将
calculateTotal绑定到数量和单价输入框的input事件,实现实时计算总价。
- 将
-
页面加载后的初始化操作:
- 调用
resizeCanvas()进行初始尺寸设置。 - 添加
window.resize事件监听器,在窗口大小改变时(使用防抖setTimeout优化性能)调用resizeCanvas()重新调整画布。 - 调用
calculateTotal()初始化总金额。 - 设置默认开票日期为当天。
- 调用
checkCanvasContent()更新初始画布状态(隐藏占位符)。 - 调用
clearCanvasInternal(true)确保历史记录以空白状态开始。 - 打印日志表示初始化完成。
- 调用
总结与注意事项:
- 库依赖: 这个方案依赖
jsPDF和html2canvas两个外部库。确保它们在使用前被正确加载(通过 CDN 或本地文件)。 - Canvas 尺寸与 DPI:
resizeCanvas函数对于在高分辨率屏幕上获得清晰的签名至关重要。 html2canvas的局限性:html2canvas是通过读取 DOM 和 CSS 来模拟渲染,它可能无法完美复现所有 CSS 效果(特别是复杂的渐变、阴影、某些 Flexbox/Grid 布局或外部字体)。对于发票这种结构相对规整的场景,效果通常较好。务必在#printable-invoice中使用稳定、兼容性好的 CSS 样式。- PDF 字体: 默认情况下,
jsPDF可能只支持有限的内置字体(如 Helvetica, Times, Courier)。如果#printable-invoice中使用了特殊字体(尤其是中文字体),html2canvas会将其渲染为图像的一部分,所以文本在 PDF 中实际上是图像,而不是可选择的文本。如果需要 PDF 中的文本可选且支持特定字体,需要配置jsPDF加载字体文件(如.ttf),这会更复杂。对于本方案,将文本渲染为图像是更简单的做法。 - 性能:
html2canvas对复杂页面进行截图可能比较耗时。签名本身(Canvas 绘图)性能通常很好。 - 安全性: 所有操作都在前端完成。如果需要将发票或签名数据保存到服务器,需要额外的后端接口和逻辑。签名数据(Data URL)可以直接发送到后端存储。
- 撤销功能: 实现了一个基于图像快照的简单撤销功能。对于非常复杂的绘制,可能会消耗较多内存。
- 用户体验: 提供了占位符、清除、撤销功能,实时计算总价,并在生成 PDF 前检查签名。响应式设计确保在不同设备上可用性。
- 代码量: 这套代码(HTML+CSS+JS)加起来应该能达到或接近您期望的行数,并且提供了相对详细的功能和注释。