
简介这是一份基于HTML构建电子发票/收据凭证界面的前端实战资源适合Web前端初学者或需要快速掌握表格动态计算逻辑的开发人员。资源把发票/收据录入、中文数字与阿拉伯数字转换、自动计算合计等功能集中在一个轻量页面中可用于小型财务场景、课程练习或个人项目演示。压缩包共3个文件包含1个可直接运行的HTML入口文件、1个7z格式的改进版工程包支持金额录入、添加/删除行、合计更新等扩展以及1张效果图PNG整体仅81KB。已有1534人学习下载。7z改进版包适合对比基础HTML与增强写法结合HTML表格和JavaScript事件可以清楚看到动态表单、中文数字处理与金额汇总的常见实现效果图则便于直观确认界面样式为后续增加输入校验、Ajax提交或打印票据等功能打下基础。1. 电子发票收据界面难点从来不在画表格看到HTML制作电子发票收据凭证界面这个任务多数人会先想到把抬头、品名、金额、税额这些字段依次排进表格里再用CSS套一个蓝白或红白的票据样式。真正做过一版就会发现界面只是最表层的工作数字要能同时支持阿拉伯数字和中文大写每行的单价×数量要实时算合计金额要在金额偶尔缺失、数量变化时保持正确最后还要能按A4纸和发票纸两种尺寸正确打印。这些逻辑堆在一起纯靠JS随手写很快会乱。这篇文章按布局→中文数字转换→自动计算→打印与校验的顺序把一版可用的实现完整过一遍。2. 用HTMLCSS搭建电子发票收据的页面骨架2.1 先定结构表格负责对账div负责排版电子发票界面和普通后台表单不同它的阅读对象是财务人员和审计人员所以布局要接近纸质票据的视觉习惯上方是票据标题与发票代码号码中间左侧是购销双方信息右侧是大写金额区域底部是明细表格与价税合计。常见做法是用一个A4比例的容器内部用CSS Grid或Flexbox分区块明细部分用table保证列对齐。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title电子发票收据凭证/title /head body div classinvoice div classinvoice-header h1电子发票普通发票/h1 div classinvoice-meta label发票代码span idcode>* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #f0f0f0; font-family: Microsoft YaHei, SimSun, sans-serif; color: #333; padding: 20px; } .invoice { width: 720px; margin: 0 auto; background: #fff; border: 1px solid #dcdcdc; padding: 30px 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } .invoice-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #e4393c; padding-bottom: 12px; margin-bottom: 16px; } .invoice-header h1 { font-size: 22px; color: #e4393c; letter-spacing: 4px; } .invoice-parties { display: flex; justify-content: space-between; margin-bottom: 18px; font-size: 14px; line-height: 1.8; } .invoice-items { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 16px; } .invoice-items th, .invoice-items td { border: 1px solid #d0d0d0; padding: 6px 8px; text-align: center; min-width: 60px; } .invoice-items th { background: #fafafa; font-weight: 600; } .invoice-items input { width: 90px; border: 1px solid transparent; background: transparent; text-align: center; font-size: 13px; padding: 2px; } .invoice-items input:focus { border-color: #409eff; background: #fff; border-radius: 2px; } .invoice-total { text-align: right; font-size: 14px; line-height: 1.9; border-top: 1px solid #e4393c; padding-top: 10px; }这里关键的几个点明细表格的input默认设置为透明边框只有聚焦时才显示边框视觉上更像打印出来的票据文字而不是可编辑表单票头使用实线红色分隔是参照国内发票的常见版式整体宽度设为720px是为了在A4纸上横向留出足够边距。屏幕预览时body背景用浅灰区分票面区域打印时再隐藏。2.3 打印样式发票能不能见人全看这一层打印样式是电子发票界面最容易翻车的地方。报表类打印可以缩放发票类打印必须按实际尺寸走因为财务归档对票面格式有要求。media print { body { background: #fff; padding: 0; } .invoice { width: 100%; border: none; box-shadow: none; padding: 10mm 8mm; } .invoice-items th { background: #fafafa !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } .invoice-items input { border: none; background: transparent !important; } page { size: A4; margin: 0; } }打印时最容易被忽略的-webkit-print-color-adjust: exact用于保留表头的浅灰底色。默认浏览器为省墨会去掉背景色不加这个属性表头底色打印出来是白的。page的margin设为0是为了让票据内容可以精确控制到纸张边缘距离实际使用时根据打印机偏移量微调。提示不同浏览器的打印比例有差异。Chrome打印对话框里默认会带边距选项选无才能和page里的margin设置保持一致。Safari对page支持较差团队内部统一用Chrome打印发票是常见做法。3. 数字转中文大写金额从一二三到壹贰叁的完整实现3.1 为什么要单独写一个转换函数财务场景中阿拉伯数字容易被人为涂改或误读所以发票和收据上必须同时出现中文大写金额。这不是简单的1→壹映射要处理零的省略规则、万亿以上单位、小数部分的角分处理。比如1005.00元应写成壹仟零伍元整而不是壹仟零零伍元100.50元应写成壹佰元伍角而不是壹佰点伍元。这些规则如果不在函数里实现靠前端拼字符串一定会出偏差。3.2 整数部分转换算法中文数字单位的层级是个十百千万亿其中万和亿是分段的关键。常见实现思路是先把数字按4位一组分段每段内部处理千百十再把万亿单位拼到段后面。function numberToChinese(num) { const digits [零, 壹, 贰, 叁, 肆, 伍, 陆, 柒, 捌, 玖]; const units [, 拾, 佰, 仟]; const bigUnits [, 万, 亿, 兆]; if (num 0) return 零; const numStr Math.floor(num).toString(); const len numStr.length; let result ; let zeroFlag false; for (let i 0; i len; i) { const digit parseInt(numStr[i]); const pos len - i - 1; const unitIndex pos % 4; const bigIndex Math.floor(pos / 4); if (digit 0) { zeroFlag true; if (unitIndex 0 pos ! 0 parseInt(numStr[i - 1]) ! 0) { result bigUnits[bigIndex]; } } else { if (zeroFlag result.length 0) { result 零; } zeroFlag false; result digits[digit] units[unitIndex]; if (unitIndex 0 parseInt(numStr[i - 1]) ! 0) { result bigUnits[bigIndex]; } } } return result.replace(/零万/g, 万).replace(/零亿/g, 亿); }这段代码的核心是pos % 4和Math.floor(pos / 4)两个计算。前者决定当前数字在千百十中的位置后者决定是否需要带万亿这种大单位。zeroFlag用于处理中间连续零的情况比如1005元遍历到千位的壹后百位和十位都是零程序不会连续输出零零而是在遇到个位伍之前只补一个零得到壹仟零伍。这个处理是中文数字转换最容易写错的地方。3.3 小数部分与元角分整数部分搞定后金额还需要处理角和分。function amountToChinese(amount) { if (isNaN(amount) || amount 0) return ; const integerPart Math.floor(amount); const decimalPart Math.round((amount - integerPart) * 100); let result numberToChinese(integerPart) 元; const jiao Math.floor(decimalPart / 10); const fen decimalPart % 10; if (jiao 0 fen 0) { result 整; } else if (jiao 0 fen 0) { result 零 捌.length 1 ? 零 分 : 分; // 上面这行写法不规范实际应写成显式拼接 result numberToChinese(integerPart) 元 零 digits[fen] 分; } else { result digits[jiao] 角; if (fen 0) { result digits[fen] 分; } } return result; }这一段有个细节值得注意Math.round((amount - integerPart) * 100)先把小数部分放大100倍再四舍五入而不是直接用amount * 100取整。因为JavaScript里0.1 0.2这类浮点运算会产生0.30000000000000004直接乘100再Math.floor可能得到29而不是30。先Math.round能规避大部分浮点精度问题。角为0分不为0时输出零分的语义不对上面代码里我特意标注了一个错误写法实际项目中应该直接用result 零 digits[fen] 分不要被中间那行干扰。为了演示时干净推荐把小数的角分处理单独抽成一个清晰版本function amountToChinese(amount) { if (isNaN(amount) || amount 0) return ; const integerPart Math.floor(amount); const decimalPart Math.round((amount - integerPart) * 100); const jiao Math.floor(decimalPart / 10); const fen decimalPart % 10; let result numberToChinese(integerPart) 元; if (jiao 0 fen 0) { result 整; } else if (jiao 0) { result 零 numberToChinese(fen) 分; } else { result numberToChinese(jiao) 角; if (fen 0) { result numberToChinese(fen) 分; } } return result; }这个版本逻辑清晰先拼元再按角分都为0角为0分不为0角不为0三种情况分别处理。100.05元会输出壹佰元零伍分100.50元输出壹佰元伍角整数部分直接用numberToChinese处理即可。3.4 边界值测试转换函数写完后用几个典型值验证零的处理和单位拼接。输入金额期望中文大写说明0零元整边界1005.00壹仟零伍元整中间连续零10050.00壹万零伍拾元整万位后补零100.50壹佰元伍角角不为0分不为0100.05壹佰元零伍分角为0分不为0999999.99玖拾玖万玖仟玖佰玖拾玖元玖角玖分满单位进位逐个跑一遍会发现numberToChinese里面的单位拼接条件和zeroFlag配合在10050这类场景下最容易错。10050遍历到千位是0、万位是1段起始位置是万位需要输出壹万但千位的零不能漏所以结果应该是壹万零伍拾而不是壹万伍拾。从代码上看万位1输出时pos4、unitIndex0、bigIndex1先输出壹万到千位时数字0置zeroFlagtrue再到十位数字5时因为zeroFlag为true且result非空先补零再输出伍拾。4. 自动计算合计单价、数量、税额的实时联动4.1 事件绑定方式的选择电子发票界面的计算逻辑集中在明细区数量×单价算出金额金额×税率算出税额最后汇总所有行的金额和税额。实现自动计算前要先决定事件绑定的粒度。常见做法有两种给每个input都绑input事件在事件回调里逐行计算或者在容器上做事件委托统一由一个函数处理。const table document.getElementById(itemsTable); table.addEventListener(input, function (event) { const target event.target; if (target.classList.contains(item-qty) || target.classList.contains(item-price) || target.classList.contains(item-rate)) { const row target.closest(tr); if (row) { calculateRow(row); calculateTotal(); } } });input事件相比change事件的好处是每次键盘输入都会触发能够快速重算。事件委托则省去为每行单独绑定的麻烦新增行时不需要重新绑定。closest(tr)用来找到触发元素所在的行确保只重算当前行而不是整个表格。4.2 行级金额与税额计算明细行的金额和税额应该全部由程序计算不允许手工编辑。function calculateRow(row) { const qty parseFloat(row.querySelector(.item-qty).value) || 0; const price parseFloat(row.querySelector(.item-price).value) || 0; const rateText row.querySelector(.item-rate).value || 0%; const rate parseFloat(rateText) / 100 || 0; const amount qty * price; const tax amount * rate; row.querySelector(.item-amount).textContent amount.toFixed(2); row.querySelector(.item-tax).textContent tax.toFixed(2); // 在行上记录未四舍五入的原始值合计时避免累积误差 row.dataset.amount amount; row.dataset.tax tax; }数量、单价、税率三个输入都可能为空字符串parseFloat()返回NaN所以用|| 0兜底确保空输入时按0处理而不是把整行算坏。金额和税额展示用toFixed(2)保留两位小数但真正参与合计的不是这个已经格式化过的字符串而是dataset里存的原值。这一步很关键——如果直接用parseFloat(textContent)去加和每行截断两位小数的误差会在合计时累积导致最后合计金额和逐行相加对不上。4.3 合计与价税合计function calculateTotal() { const rows document.querySelectorAll(#itemsTable tbody tr); let totalAmount 0; let totalTax 0; rows.forEach(row { totalAmount parseFloat(row.dataset.amount) || 0; totalTax parseFloat(row.dataset.tax) || 0; }); const totalGross totalAmount totalTax; document.getElementById(totalAmount).textContent totalAmount.toFixed(2); document.getElementById(totalTax).textContent totalTax.toFixed(2); document.getElementById(totalGross).textContent totalGross.toFixed(2); document.getElementById(totalGrossCn).textContent amountToChinese(totalGross); }合计区显示的是价税分离后的数据合计金额、合计税额、价税合计小写、价税合计大写。这里totalGross是一个浮点数直接传给上一章的amountToChinese。注意amountToChinese内部有Math.round((amount - integerPart) * 100)保护浮点误差在转换函数里被吸收不会出现大写金额和显示的小写金额不一致的情况。4.4 新增行与删除行的联动实际开票时明细行数是变化的所以表格要支持动态增删行。function addRow() { const tbody document.querySelector(#itemsTable tbody); const tr document.createElement(tr); tr.innerHTML tdinput typetext classitem-name value/td tdinput typetext classitem-spec value/td tdinput typetext classitem-unit value项/td tdinput typenumber classitem-qty value1 min0/td tdinput typenumber classitem-price value0.00 min0/td td classitem-amount0.00/td tdinput typetext classitem-rate value6%/td td classitem-tax0.00/td tdbutton classbtn-delete删除/button/td ; tbody.appendChild(tr); calculateTotal(); }新增行时把数量默认设为1单价设为0这样新行不会影响合计。删除操作要监听按钮点击不能只删tr不重算合计。table.addEventListener(click, function (event) { if (event.target.classList.contains(btn-delete)) { const rows document.querySelectorAll(#itemsTable tbody tr); if (rows.length 1) { event.target.closest(tr).remove(); calculateTotal(); } else { alert(至少保留一行明细); } } });保留至少一行是常见约束防止把表格删空后合计区显示异常。删除后立即调用calculateTotal保证累计数据实时更新。提示税率字段目前是文本用户可以输入13%、6%、3%等。如果只是读取parseFloat(rateText)6%会正确解析为6但用户输入13.5%会得到13.5这没问题。要注意的是用户可能输入增值税13%这类带前缀的文本parseFloat会返回NaN所以要么在input事件里校验格式要么用正则先抽取数字才能避免把税率算成0。5. 打印与格式校验的几个实用技巧5.1 用数据属性保存计算原值明细行中item-amount显示的是格式化后的文本如果打印前又读取这个文本参与其他计算会再次引入精度问题。一个简单的技巧是用row.dataset.amount保存未格式化的原始值需要时直接取用。除此之外还可以把发票代码、号码存在>const invoiceCode document.getElementById(code).dataset.value; const invoiceNumber document.getElementById(number).dataset.value;5.2 打印前触发一次强制重算用户可能把数量输入到一半就直接点打印此时界面上金额可能还停留在上一轮计算结果。在打印按钮的处理函数里主动调用一次calculateTotal()可以避免这种情况。# 前端直接调用浏览器打印对话框 window.print()在调用前先执行calculateTotal()再调用window.print()是低成本且有效的一层保障。如果是自定义打印模板可以监听window.onbeforeprint事件做同样的处理。5.3 票面要素完整性校验电子发票的票面要素可以直接在JS里校验不需要提交到后端。需要检查的字段包括购方名称非空、纳税人识别号格式18位、明细行至少一行且数量单价大于0、金额合计大于0。校验不过时在界面直接标红提示。function validateInvoice() { const buyer document.getElementById(buyer).textContent.trim(); const buyerTax document.getElementById(buyerTax).textContent.trim(); const total parseFloat(document.getElementById(totalAmount).textContent); if (!buyer) { alert(购方名称不能为空); return false; } if (!/^[0-9A-HJ-NPQRTUWXY]{18}$/.test(buyerTax)) { alert(纳税人识别号格式不正确); return false; } if (total 0) { alert(合计金额必须大于0); return false; } return true; }纳税人识别号的18位格式并不能完全保证号码真实存在但能拦住大部分手误。校验通过后再打开打印对话框实际项目里也可以把这条校验放到window.print()之前保证打印出来的票面是完整可归档的。本文还有配套的精品资源点击获取