资讯动态

发票样板H5开发实战:版式拆解、数据填充与打印调优

发布时间:2026/10/20 11:57:12 来源:尧图企业网站定制
“发票样板 H5”这几个字放在一起第一反应就是要在浏览器里做出一张像模像样的发票样式页面支持填数据、改金额、打印出来当样品。我做这个需求时业务部门拿到的还是一张静态图片改个公司名称就得找设计重新修图改多了双方都累。所以我当时决定直接上 H5把版式固定好、数据做成可配置、打印样式一次性调干净。这篇文章把我从拆版式到写完代码、调完打印的几个关键步骤以及中间翻过的车完整整理一遍给你做同类需求时省点试错时间。1. 发票样板 H5 并不只是“画一张发票”1.1 一张样板页的业务价值发票样板页面最常见的应用场景有三类。第一类是给客户演示开票结果客户不想等真实开票流程直接看一张“样板发票”就能确认字段和布局是否满足需求第二类是财务系统的前端原型需要先用一张假票据验证界面开发人员才有依据去对接正式接口第三类是自动化测试的夹具页面用固定数据渲染一张票据样张用来验证打印、导出等功能。这三个场景都有一个共同要求页面必须支持动态填入数据、快速刷新展示、可打印输出。如果只输出一张图片后面两类场景基本没法落地。H5 用 HTML 写版式、用 JavaScript 控制数据天然能满足这些要求。我做项目时经常提醒自己样板页面的目标是“以假乱真”这个“假”是指数据是模拟的但版式和打印效果必须接近真实票据否则业务方看不出问题后续返工成本反而更高。1.2 为什么不能直接用图片或 PDF很多人第一反应是用一张发票截图简单直接。但图片是位图字段稍微变一下就得重新截图改起来非常痛苦如果要在手机 H5 里展示图片体积还会拖慢加载速度放大后边角发虚。PDF 适合定稿存档但要在页面里做动态填数和在线预览交互成本很高通常还得引入额外的查看器控件体验不一定好。H5 的优势在于“版式可复用、数据可分离”。CSS 负责把发票长什么样描述清楚JavaScript 负责把数据填进对应位置同一套代码既能嵌入业务系统也能单独分享链接。业务方只需要打开页面左侧输入框改几个字段右侧预览区马上就能刷新成一张新发票。这种交互方式图片和 PDF 都做不到。1.3 这个页面到底适合谁使用使用这类样板页面的主要是三拨人。前端开发拿它做模板和适配产品经理拿它快速确认发票字段和布局测试或运营人员拿它准备演示数据和截图。三类人关注点不太一样但都希望样板页面能改、能打、能看。我建议动手前先确认谁是这个页面的最终使用者因为交互深度完全不同。只给内部预览用做一个简单的填充表单就够了。对外演示可能要加二维码和防伪信息。如果要打印出来盖章就得做到每一毫米的位置不偏。不要一开始就把需求想成“画一张好看的图”要想清“哪些字段可变、哪些结构固定、输出形态是什么”否则很容易做出一堆表面功夫实际没法用。2. 动手前先拆版式和数据模型再写代码2.1 发票版式先定义纸张和坐标拿到需求后别急着写 HTML第一件事是搞清楚发票的物理尺寸和分区。拿最常用的电子发票样张来说常见尺寸是 210mm 宽、148mm 高也有企业会自定义尺寸。客户说“要跟真票一样”听起来抽象核心其实就是三个尺寸纸张宽高、四周边距、表格列宽。我习惯先列一个区块坐标表把一整个票据拆成几个大区域区块主要内容位置说明票头区发票名称、发票代码、发票号码顶部居中或左右分布信息区购买方信息、销售方信息票头下方左右各半明细区项目名称、数量、单价、金额、税额页面中部表格价税合计区小写合计金额、大写合计金额明细区下方签章区收款人、复核、开票人、备注页面底部有了这张坐标表CSS 里的盒子布局才不容易跑偏。很多人一上来就忙着调颜色、加阴影结果打印时不是高了就是宽了。发票样板的还原目标很明确先像一张票再谈好不好看。2.2 数据字段与真实票据字段对齐发票样板的核心是数据字段不是样式。如果字段名和真实票据对不上后面接后端接口时又得重改。我把数据对象设计成和业务字段尽量一一对应例如const invoiceData { title: 电子发票普通发票样张, invoiceCode: 031002300211, invoiceNumber: 12345678, issueDate: 2024-05-20, checkCode: 31140988712045632109, buyer: { name: 示例购买方公司, taxId: 91440101MA5XXXXXX0, bank: 某某银行某某支行, account: 6809 8899 1020 3040 5060 }, seller: { name: 示例销售方公司, taxId: 91440101MA5XXXXXX1, bank: 某某银行某某支行, account: 6809 8899 1020 3040 5061 }, items: [ { name: 技术服务费, spec: , unit: 项, quantity: 1, price: 1000, amount: 1000, taxRate: 6, tax: 60 } ], totalAmount: 1000, totalTax: 60, total: 1060, totalUpper: 壹仟零陆拾元整, remarks: 备注信息 };字段名用驼峰命名方便前端处理后端如果返回的是下划线命名在接口层做一次映射即可。不要直接在模板里写死字段更不要在页面里到处硬编码“购买方”“销售方”这类文案因为不同发票类型对同一字段的称呼可能略有差异。2.3 “版式层”和“数据层”分离的好处我最初写样板时偷懒把数据直接写死在 HTML 里结果改一处就得全局替换非常痛苦。后来改成用>div classinvoice-wrap div classinvoice div classinvoice-header h1 classinvoice-title电子发票普通发票样张/h1 div classinvoice-no span发票代码em>* { box-sizing: border-box; } body { margin: 0; padding: 16px; background: #f5f6f8; font-family: SimSun, Songti SC, Noto Serif CJK SC, Serif; } .invoice-wrap { display: flex; justify-content: center; } .invoice { width: 210mm; min-height: 148mm; background: #fff; padding: 8mm 12mm; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); font-size: 14px; color: #222; } .invoice-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444; padding-bottom: 4mm; } .invoice-title { font-size: 24px; font-weight: 700; margin: 0; } .invoice-table { width: 100%; border-collapse: collapse; table-layout: fixed; margin: 6mm 0; } .invoice-table th, .invoice-table td { border: 1px solid #444; padding: 3mm 2mm; text-align: center; font-size: 13px; }为什么用box-sizing: border-box因为发票宽度定义成 210mm如果再用默认的content-box加 padding总宽度会超过 210mm打印时必然溢出。屏幕预览如果觉得版面太小可以给.invoice加transform: scale()但打印样式里必须移除否则打印结果会莫名其妙地被缩小或位移。3.3 JavaScript 动态填充数据的写法填充数据的关键是遍历所有[data-field]元素按字段路径找到对应值并写入。我封装了一个通用的fillInvoice方法function getValueByPath(obj, path) { return path.split(.).reduce((acc, key) acc acc[key], obj); } function escapeHtml(str) { return String(str) .replaceAll(, amp;) .replaceAll(, lt;) .replaceAll(, gt;) .replaceAll(, quot;); } function fillInvoice(data) { document.querySelectorAll([data-field]).forEach(el { const path el.dataset.field; const value getValueByPath(data, path); if (value ! undefined value ! null) { el.textContent escapeHtml(value); } }); renderItems(data.items || []); } function renderItems(items) { const tbody document.querySelector(#invoiceItems); tbody.innerHTML items.map(item tr td${escapeHtml(item.name)}/td td${escapeHtml(item.spec || )}/td td${escapeHtml(item.unit || )}/td td${formatNumber(item.quantity)}/td td${formatMoney(item.price)}/td td${formatMoney(item.amount)}/td td${escapeHtml(item.taxRate)}%/td td${formatMoney(item.tax)}/td /tr ).join(); }这里有两个重点。第一为什么用textContent而不是直接赋值innerHTML因为发票里的公司名称、备注字段可能包含特殊符号直接插入 HTML 会引发样式错乱甚至脚本注入。第二表格行可以拼接字符串但每个字段都要经过escapeHtml转义。执行fillInvoice(invoiceData)后整个版面就会自动更新。3.4 金额格式化与中文大写转换金额显示基本都会涉及千分位和两位小数。用toLocaleString做格式化比较稳妥function formatMoney(value) { const num Number(value) || 0; return num.toLocaleString(zh-CN, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function formatNumber(value) { const num Number(value) || 0; return num.toLocaleString(zh-CN, { maximumFractionDigits: 4 }); }中文大写转换是发票样板绕不开的环节。最稳妥的方案是引入一个经过大量测试的开源库但如果项目不允许加额外依赖可以自己写一个精简版本。核心逻辑是把整数部分按“千、百、十、个”分组再插入“万、亿”等单位小数部分单独处理“角、分”。function convertCurrencyToChinese(amount) { const digits [零, 壹, 贰, 叁, 肆, 伍, 陆, 柒, 捌, 玖]; const radices [, 拾, 佰, 仟]; const bigRadices [, 万, 亿]; const money Number(amount); if (isNaN(money) || money 0) return 金额错误; if (money 0) return 零元整; const integer Math.floor(money); const decimal Math.round((money - integer) * 100); let intStr String(integer); let result ; let zeroCount 0; for (let i 0; i intStr.length; i) { const digit Number(intStr[i]); const unitIndex (intStr.length - i - 1) % 4; const groupIndex Math.floor((intStr.length - i - 1) / 4); if (digit 0) { zeroCount; if (unitIndex 0 groupIndex 0 zeroCount 4) { if (result !result.endsWith(零)) { result 零; } result bigRadices[groupIndex]; zeroCount 0; } } else { if (zeroCount 0) { result 零; } zeroCount 0; result digits[digit] radices[unitIndex]; if (unitIndex 0 groupIndex 0) { result bigRadices[groupIndex]; } } } if (decimal 0) { return result 元整; } const jiao Math.floor(decimal / 10); const fen decimal % 10; let decimalStr jiao 0 ? digits[jiao] 角 : 零; if (fen 0) { decimalStr digits[fen] 分; } return result 元 decimalStr; }这个函数只适合常规金额边界情况建议补测试用例例如“100000000.03”“100200”这类容易在“零”上出错的值。实际项目里如果后端接口已经返回了大写金额字符串前端直接展示即可不需要自己再转换一遍减少出错概率。3.5 打印前你需要处理的三件事第一次让发票样板开放打印功能时我遇到过不少问题现在总结了三个必做动作。第一把非打印区域分清楚。页面上的编辑按钮、输入表单、调试面板都要加一个类打印时统一隐藏button classbtn no-print onclickwindow.print()打印/buttonmedia print { .no-print { display: none !important; } }第二设置打印页边距。直接在 CSS 里声明page可以控制纸张大小和边距但不同浏览器的兼容性有些差异最好同时把对话里的边距选项设置为“无”或“默认”。page { size: 210mm 148mm; margin: 0; }第三保留颜色和背景。发票通常有红色印章、灰色背景区块浏览器为了让打印省墨默认可能去掉背景色。需要加.invoice { -webkit-print-color-adjust: exact; print-color-adjust: exact; }打印按钮建议放在预览区外面不要遮住票据。点击打印前再执行一次fillInvoice(currentData)防止用户改了表单但没有触发预览刷新最后打印出来是老数据。4. 常见问题我踩过的几个坑4.1 打印偏移page 和边距的关系最常遇到的问题是打印出来的发票整体偏右或偏下有时还多出一页空白。原因大多不是代码没写对而是浏览器打印设定了额外的页边距。Chrome 打印对话框里有一个“边距”选项默认选择是“默认”这时即使 CSS 写了page { margin: 0 }页面也未必完全贴合纸张边缘。我现在的排查顺序是先看预览里是不是有页面缩放残留再看打印对话框的边距设置最后检查.invoice是否在media print里去掉了transform: scale()。开发阶段建议每隔一段时间就跑一次打印预览不要等所有样式写完再调越到后期越难定位是哪个样式影响了偏移。提示打印预览是你最诚实的验收标准。屏幕里再好看打印输出不对就是不对。这个道理我在连续踩了两天坑之后才彻底记住。4.2 表格断裂和字体串位发票明细行一多表格在分页时很容易被切断上半页下半行、下半页上半行看着非常不舒服。解决办法是给表格行设置分页保护.invoice-table tr { page-break-inside: avoid; } .invoice-table thead { display: table-header-group; }display: table-header-group的作用是当表格跨页时每页都会重复表头这样打印出来的多页票据更清晰。字体串位是另一个容易被忽略的问题。发票通常用宋体或黑体但 Windows 和 macOS 对中文字体的名称处理不一样。Windows 里SimSun是宋体macOS 没有这个名字反过来Songti SC在 Windows 里又不存在。我一般写一组回退字体.invoice { font-family: SimSun, Songti SC, Noto Serif CJK SC, Serif; }这样不同系统至少能呈现相近的中文衬线风格。嵌入字体虽然最保险但会让 H5 页面体积明显变大对样板页不太划算。4.3 长文本破坏版式公司名称、地址、备注字段偶尔超长尤其是“开户银行及账号”这一行稍微一长就把整行撑破。处理方案分两步。第一步明细表格使用table-layout: fixed固定每一列的宽度比例让文本在单元格内部折行而不是撑开表格.invoice-table { table-layout: fixed; } .invoice-table td { word-break: break-all; overflow-wrap: anywhere; }第二步对有最长字符限制的字段在前端先做一次截断或省略。比如纳税人识别号经常有固定位数如果数据超过位数多半是测试数据写错了打印出来反而暴露问题。空单元格也要保留高度。像我这种“规格型号”和“单位”经常为空的情况不要在 JS 里直接把整行删除或把字段替换成空字符串而且不加占位否则行高会塌掉整张发票的留白比例完全变形。4.4 一些“发票校验”相关的显示细节这里不讨论真实发票的税务规则只说前端显示上的几个细节。第一校验码字段大多数时候是一组长数字打印出来要有适当的字符间距不能挤成一团。第二密码区不是普通文本在样张里可以用***或空格占位但不要随便放一串看起来像真实密码的数字。第三金额为 0 时要显示“0.00”不能出现“.00”这样的半截格式。税率也有例外情况。有的票据税率不是数字而是“不征税”“免税”这类文本如果前端一刀切把它转成数字再格式化就会直接显示NaN%或者0%明显不对。处理方式是数据结构里加一个taxType字段或者让税率字段直接支持字符串渲染时先判断类型再选择格式化逻辑。5. 再把视野打开一点发票样板的后续扩展5.1 从写死的样板到可视化模板编辑器样板页面做到一定程度一定会遇到新的需求把某一列的宽度调一下把公司名称的字体加大把备注区放到左下角而不是右下角。如果每次都是开发改 CSS效率很低。后续可以做一个可视化模板编辑器让业务人员自己配置发票的区块位置、字段绑定、字体大小和边框样式。本质上还是“版式和数据分离”这套思路。用一个 schema 描述每个区块的坐标、数据字段、样式属性系统读取 schema 之后动态渲染 H5 预览。做编辑器时要注意编辑态和打印预览态必须分开。编辑态需要拖拽辅助线和可点击的控件打印预览态则应屏蔽所有编辑辅助元素只保留纯票据版面。5.2 对接后端开票数据的正确姿势样板页面最终大多要接入开票接口但这不等于前端可以自己做“发票生成”。正确做法是前端只负责把后端返回的发票数据渲染到页面上开票动作和票据有效性全部交给后端或第三方服务处理。前端不要自己去计算税额不要自己拼发票号码更不要用 H5 页面去生成真实票据。真实开票涉及很多外部规范不是一个网页能搞定的。尤其是对外演示时页面要明确标注“样板”或“样例”避免有人截图后误当成正式发票使用。这个点比技术本身重要我甚至建议在页面的固定角落加一个浅色水印比如“仅供演示”既不影响版式又能避免后续扯皮。5.3 二维码、查验区和其他组件电子发票右下角通常有二维码和查验提示。在样板页面里二维码一般只是一个img标签图片地址由后端生成后返回前端不需要自己实现二维码逻辑。div classinvoice-qr img>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑