Old-School Front-End / 2026 ・ No.13
古法前端入门
第13讲 汇总与打印 ── 按月相加,亲手画柱状图,再打印到纸上
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第13讲是汇总与打印。
菜单里一直有个“仪表盘”,可点进去什么都没有。
这次在那里放上按月・按类别・按人相加的表格,以及用 SVG 亲手画的柱状图。
然后让它能漂亮地打印到财务部长最爱的纸上。途中,还会踩到日期和时区的老坑。
本讲的目标
古井财务部长交代了:“把 7 月以来的费用按月汇总,打印成纸给我。”
新井纸……?不是 PDF?
古井是纸。听说要用红笔画圈。
新井那还得有图表吧。装个 Chart.js 一行就搞定。交给 AI 3 秒钟。
古井用库不是坏事。但今天亲手画。柱状图说到底,就是把长方形排起来而已。懂了里面的道理,库画出奇怪的图时你才能察觉。
当前位置:第13讲
- 定好计入哪些 ── 只加已批准・已报销的。待审批的另外显示
- 分组相加 ── 按月・按类别・按人。都用同一个函数,只换生成键的方法
- 亲手画柱状图 ── 用 JavaScript 排列 SVG 的长方形。数字也放进表格
- 打印到纸上 ── 用打印用的 CSS 去掉菜单,加上日期时间和打印人,整理好分页
对面是在上一讲的练习用服务器上加了汇总用的 /api/report。数据也稍微多了一些。7〜9 月的部分,设定是古井以前用 Excel 台账管理、现在搬过来的(所以编号是 XL-2026-07-01 这样的形式)。申请人里还加入了佐藤 健([email protected] / demo-sato)。全部是仅限演示的虚构数据。
计入哪些
同样说“费用合计”,加哪些进去,数字就不一样。审批中的算不算?被退回的呢?草稿呢?写代码之前,先把这个定下来。
只加“要付钱・已付钱”的
这次和财务部长商量(设定如此)后,决定加已批准和已报销的。审批中的还不确定会不会付,所以不计入合计,作为“待审批”放在另一张卡片里。草稿和已退回的不计。这个约定起个名字,放在代码开头。
const COUNTED = ['approved', 'paid']; // 计入汇总的,只有要付钱(已付钱)的申请
能看到谁的申请,和第11讲一样由服务器决定。/api/report 给审批人返回所有人的申请,给申请人只返回自己的申请。页面只负责把收到的东西加起来。
分组相加
按月合计,就是“把申请按月分开,各自相加”。做一个分组的标记(键),往每个键对应的箱子里加。箱子用 Map。
生成键,加到同一个键的箱子里
/** 把 items 按 keyOf(item) 分组,累加笔数和金额 */
function sumBy(items, keyOf) {
const totals = new Map();
for (const item of items) {
const key = keyOf(item);
const t = totals.get(key) ?? { count: 0, amount: 0 };
t.count += 1;
t.amount += item.amount;
totals.set(key, t);
}
return totals;
}
第7讲的 calcTotal 是“全部加成一个”的函数。sumBy 是“按键分别相加”的版本。生成键的方法用函数传进去,所以按月、按类别、按人都能用。?? 的意思是“左边没有就用右边”。第一次出现的键,从 0 笔・0 日元的箱子开始。也有人用 reduce 一行写完,这里和第7讲一样,用 for…of 一步一步写。
用的地方是这样的。
const counted = report.claims.filter((c) => COUNTED.includes(c.status));
const waiting = report.claims.filter((c) => c.status === 'submitted');
const total = calcTotal(counted); // 第7讲的函数
// 按月
const byMonth = sumBy(counted, (c) => monthOf(c.date));
const months = monthRange(report.from, report.to).map((m) => ({ label: monthLabel(m), ...(byMonth.get(m) ?? { count: 0, amount: 0 }) }));
drawBars(document.querySelector('#chart-month'), months.map((m) => ({ label: m.label, value: m.amount })));
document.querySelector('#by-month').replaceChildren(...months.map((m) => row([m.label, String(m.count), yen(m.amount)], [1, 2])));
document.querySelector('#by-month-total').replaceChildren(row(['合计', String(counted.length), yen(total)], [1, 2]));
// 类别不在申请上,而在明细行上,所以先把行收集起来再相加
const lines = counted.flatMap((c) => c.rows);
const byCat = [...sumBy(lines, (r) => r.category)].sort((a, b) => b[1].amount - a[1].amount);
- 月份列表不从 Map 取,而是用 monthRange 生成。Map 里只有“有申请的月份”,没有申请的月份会悄悄从图表上消失
- 类别不在申请上,而在明细行上,所以先用 flatMap 把行排成一列再相加。纽约出差会拆成交通费・住宿费・会议费 3 行分别计入
新井不用 Map,用普通对象 {} 也行吧?
古井行。不过对象一开始就挂着 toString 之类的东西。不知道键会是什么的时候,什么都没装的 Map 更放心。
日期按字符截取
月份的键是 '2026-10-16'.slice(0, 7),也就是前 7 个字符。你也许会想:“既然是日期,转成 Date 再用 getMonth() 取才对吧?”试试看。
# 用同样的字符串创建的 Date 的“月”(在 Chromium 里改时区实测) 时区 new Date('2026-07-01') new Date('2026-07-01T00:00') .slice(0, 7) 东京 7月 7月 2026-07 上海 7月 7月 2026-07 伦敦 7月 7月 2026-07 洛杉矶 6月 7月 2026-07 ← 变成了6月30日傍晚 # 在洛杉矶 new Date('2026-07-01') 的内容:Tue Jun 30 2026 17:00:00
只有洛杉矶,7 月变成了 6 月。只有日期的 '2026-07-01',规定按世界时(UTC)7月1日0点读取。那一刻东京是 7月1日早上 9 点,洛杉矶却还是 6月30日傍晚 5 点。所以问“几月?”,它回答 6 月。麻烦的是,带时间的 '2026-07-01T00:00' 反而按“当地的 0 点”读取。第9讲错开的是“今天”,这次错开的是“月份”。
同一个 new Date('2026-07-01'),在不同地方是不同的日期
在仪表盘上试了一下。用洛杉矶时间的浏览器打开,把 monthOf 换成“先转成 Date 再取月”的版本。
# 按月的表:在洛杉矶时间打开时(Chromium 实测) 月份 按字符截取(正确) 先转成 Date 再取月 7月 4 / 67,720 4 / 87,740 8月 4 / 57,660 4 / 74,100 9月 4 / 65,730 3 / 27,290 10月 6 / 90,971 6 / 90,971 把表的各行相加 17 笔 / 280,101 合计行 18 笔 / 282,081
1 号提交的申请,全都搬到了前一个月。7月1日的 1,980 日元去了 6 月,从表格范围里消失了。把表的各行相加是 17 笔・280,101 日元,合计行却是 18 笔・282,081 日元。合计行是把所有申请加起来的,所以不会错。亲手做汇总表的财务人员最后要把横竖都加一遍确认(“验算”),就是为了这种时候。只在东京试的话,这个坑是看不见的。
只是按月分组的话,最安全的是不把日期转成 Date。页面上显示“10月”时,也像 new Date(2026, 9, 1) 这样用数字传入年・月・日。这种写法按当地时间创建,不会错开。
/** '2026-10-16' → '2026-10'。日期不转成 Date,直接按字符截取(第四节的坑) */
function monthOf(date) {
return date.slice(0, 7);
}
/** 把 '2026-07' 到 '2026-10' 的月份不遗漏地排出来(没有申请的月份也显示 0) */
function monthRange(from, to) {
const months = [];
let [y, m] = from.split('-').map(Number);
const [toY, toM] = to.split('-').map(Number);
while (y < toY || (y === toY && m <= toM)) {
months.push(`${y}-${String(m).padStart(2, '0')}`);
m += 1;
if (m > 12) { y += 1; m = 1; }
}
return months;
}
/** '2026-10' → 该语言的月份写法(中文是“10月”) */
function monthLabel(month) {
const [y, m] = month.split('-').map(Number);
return new Date(y, m - 1, 1).toLocaleDateString(LOC, { month: 'short' }); // 用数字传入年・月・日,会按当地时间创建
}
亲手画柱状图
图表用 SVG 来画。在 HTML 里放一个 <svg id="chart-month">,用 JavaScript 往里加长方形(rect)和文字(text)。先弄清坐标的规矩。
SVG 的 y 向下增加
/** 创建 SVG 元素。不是 createElement,而是 createElementNS(需要命名空间) */
function svgEl(name, attrs = {}, text = '') {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
el.textContent = text;
return el;
}
/** 画竖向柱状图。data = [{ label, value }] */
function drawBars(svg, data) {
const W = 640, H = 240, TOP = 28, BOTTOM = 30;
const max = Math.max(...data.map((d) => d.value), 1);
const step = W / data.length;
const barW = step * 0.55;
const parts = [svgEl('line', { x1: 0, y1: H - BOTTOM, x2: W, y2: H - BOTTOM, class: 'chart-axis' })];
data.forEach((d, i) => {
const h = (H - TOP - BOTTOM) * d.value / max;
const x = step * i + (step - barW) / 2;
const y = H - BOTTOM - h; // SVG 的 y 轴朝下。柱子的上端 = 轴的位置 − 柱子的高度
parts.push(svgEl('rect', { x, y, width: barW, height: h, class: 'chart-bar' }));
parts.push(svgEl('text', { x: x + barW / 2, y: y - 8, class: 'chart-value' }, d.value.toLocaleString(LOC)));
parts.push(svgEl('text', { x: x + barW / 2, y: H - 8, class: 'chart-label' }, d.label));
});
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.replaceChildren(...parts);
}
把金额最大的那个月当作“满高”,其他月份按比例缩短。柱子上面写金额,下面写月份。颜色不写在 SVG 属性里,而是用 CSS(.chart-bar { fill: #1f4e6b; })加。用 viewBox 把坐标范围定为 640×240,所以页面宽度变了,只靠 CSS 的 width: 100% 就能伸缩。
这里有一个古法时代的人都踩过一次的坑。svgEl 里用的不是 createElement,而是 createElementNS。用普通的 createElement('rect') 创建会怎样?
# 往 <svg> 里加一个 rect 试试(Chromium 实测) 创建方法 命名空间 实际类型 大小 createElement http://www.w3.org/1999/xhtml HTMLUnknownElement 0 × 0 ← 画不出来 createElementNS http://www.w3.org/2000/svg SVGRectElement 84 × 42
用 createElement 创建的 rect,成了 HTML 的“不认识的元素”(HTMLUnknownElement),大小为 0,什么也画不出来。也不报错。SVG 的元素属于和 HTML 不同的命名空间(http://www.w3.org/2000/svg),创建时必须报上这个名字。
图表加上了 role="img" 和 aria-labelledby,用下面的说明文字(figcaption)作为名称。数字本身也全部放在旁边的表格里。图表是一眼就懂的画,准确的数字在表格里。在打印的纸上用红笔画圈的,大概也是表格。
占比的尾数
按类别的明细里,用 1 位小数显示了占整体的比例。加起来看看。
# 各类别的占比(toFixed(1)。Chromium 实测) 类别 金额(日元) 占比 交通费 126,170 44.7% 住宿费 69,058 24.5% 会议・招待费 45,723 16.2% 其他 30,280 10.7% 消耗品 10,850 3.8% 加起来 99.9%
变成了 99.9%。这不是算错了。各自四舍五入后,进上去的和舍掉的不一定互相抵消。合计行如果写“100.0%”,在纸上验算的人会说“对不上”。所以合计行不写占比,而在表格下面加一句“加起来不一定是 100.0%”。一定要凑齐的话,可以和财务商量定个规则,比如把差额加到尾数最大的那一项上。
新井0.1% 而已,谁会在意啊。
古井财务部长在意。会被红笔画圈。
打印到纸上
“打印”按钮做的事,只是用 window.print() 打开浏览器的打印界面。打印到纸上是什么样子,写在 CSS 的 @media print 里。屏幕和纸需要的东西不一样。
纸上点不了的去掉,只有纸上需要的加上
去掉和加上,用 Bootstrap 的类就行。d-print-none 是只在打印时隐藏,d-none d-print-block 是在屏幕上隐藏、只在打印时显示。
<header class="d-print-none navbar navbar-expand-md navbar-dark brand-bar"> … </header> <p class="print-head d-none d-print-block"><strong>报销报告</strong> <span id="print-meta"></span></p> <div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-1"> <h1 class="h3 mb-0">仪表盘</h1> <button type="button" class="btn btn-outline-secondary d-print-none" id="print">打印</button> … <footer class="d-print-none py-4 text-center text-body-secondary"> … </footer>
打印用的标题里,用 JavaScript 填入范围・打印的日期时间・打印人。纸在桌上会混在一起,写上“哪天、谁打印的”会很有帮助。剩下的自己写 CSS。
/* 第13讲:表示类别占比的横条(用背景色涂) */
.share-bar {
display: block;
height: .6rem;
border-radius: 2px;
background-color: #c0392b;
}
/* 第13讲:打印的时候 */
@page {
size: A4;
margin: 14mm 12mm;
}
@media print {
body {
background: #fff !important;
font-size: 10pt;
}
.container {
max-width: none;
padding: 0;
}
.keep,
.card,
tr {
break-inside: avoid; /* 不要在中间分页 */
}
h2 {
break-after: avoid; /* 不让标题单独留在页面最后 */
}
.share-bar {
-webkit-print-color-adjust: exact; /* 给旧浏览器 */
print-color-adjust: exact; /* 背景色也打印(浏览器默认不打印背景) */
}
.report-list th,
.report-list td {
white-space: nowrap; /* 编号和日期不要中途换行 */
}
.report-list td:nth-child(4) {
white-space: normal; /* 只有事由可以换行 */
}
.print-head {
border-bottom: 2px solid #212529;
padding-bottom: .25rem;
}
}
- 用 @page 定纸的大小和页边距
- 用 break-inside: avoid,不在卡片、表格行、图表的中间分页
- 用 print-color-adjust: exact,让横条的背景色也打印出来
最后一条之所以需要,是因为浏览器默认不打印背景色和背景图片(为了省墨。在打印界面勾选“背景图形”就会打印)。类别的横条是用背景色涂的,直接打印会在纸上消失。而图表的柱子是 SVG 的 fill,也就是图本身,不会消失。在 Chromium 里输出 PDF 确认过。
# 打印时的样子(Chromium。模拟 @media print,并输出 PDF 实测) 检查的对象 结果 页头(d-print-none) display: none → 不打印 “打印”按钮 display: none 打印用的标题(d-print-block) display: block → 只在打印时出现 A4 的 PDF 3 页。第 3 页的表格也带上了表头 图表的柱子(SVG 的 fill) 在不打印背景的设置下也打印出来了 类别的横条(CSS 背景色) 有 print-color-adjust: exact → 打印出来 / 没有 → 消失
第 1 页是汇总和按月的图表,第 2 页是明细和申请列表,第 3 页是列表的后续。第 3 页的表格也再次带上了表头(表格的 thead 跨页时会重复打印)。按类别的明细在第 1 页下面放不下,因为 break-inside: avoid,整块被送到了第 2 页。另外,实际从浏览器打印时,浏览器有时会在页边加上 URL 和日期。这可以在打印界面的“页眉和页脚”里关掉。
动手试试
启动练习用服务器,用古井(审批人)和新井(申请人),通过 Playwright 实际打开。也试了洛杉矶时间的浏览器和不打印背景的 PDF。
# 是谁打开的(Chromium 实测) 古井(审批人) 新井(申请人) 汇总的费用 282,081 日元 181,301 日元 笔数 18 笔 9 笔 待审批 11,900 日元 0 日元 按人合计的表 显示 不显示 计入汇总的申请 18 行 9 行
- 同一个 dashboard.html,古井看到的是所有人的 18 笔,新井看到的是自己的 9 笔。新井的页面上没有按人合计的表(服务器也没有把别人的申请交给他)
- 新井的待审批是 0 日元。因为上一讲 0012 和 0007 都被批准了
- 在洛杉矶时间转成 Date 后,1 号的申请错到了前一个月,表的各行和合计行对不上了。按字符截取的版本,在哪个时区数字都一样
- 在不打印背景的设置下,去掉 print-color-adjust 横条就消失,加上就留着
汇总页面最重要的,不是好看,而是数字对得上。定好计入哪些并写清楚。日期按字符处理。换个时区,确认表的各行和合计行是否对得上。图表是把这些数字画成图,打印时只留下读纸的人需要的东西。
结 — 一页总结与下讲预告
- 合计之前,先定计入哪些。这次是已批准・已报销。待审批另外显示。
- 分组相加用 Map 和键。月份列表用 monthRange 生成,0 的月份也不丢。
- 只是按月分组的话,日期不转成 Date,按字符截取。'2026-07-01' 会按世界时 0 点读取。
- SVG 元素用 createElementNS 创建。y 朝下。准确的数字也放进表格。
- 占比的尾数,加起来不一定是 100.0%。定好写法,加上注释。
- 打印用 @media print。用 d-print-none 隐藏,用 @page 和 break-inside 整理,背景色用 print-color-adjust。
新井把纸交给部长,7 月的福冈出差被画了红圈。问“这是什么”。
古井那是你的出差。去解释吧。……下一讲是 jQuery。把第8〜10讲亲手写的 DOM・事件・通信用 jQuery 重写,并排比较。古法中的古法。
新井就是满屏美元符号($)的那个吧。
参考
- MDN — Date.parse() (date-only forms are UTC)
- MDN — Date() constructor
- MDN — Document: createElementNS() method
- MDN — SVG namespaces crash course
- MDN — Printing (CSS)
- MDN — print-color-adjust
- MDN — break-inside
- MDN — @page size
- Bootstrap 5.3 — Display property (Display in print)
页面截图是笔者制作的演示页面,经由练习用服务器(Node.js),于2026年10月19日在云端的 Chromium(Playwright)中拍摄(页面时钟固定在 2026年10月17日 15:00)。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。账号・申请・金额都是练习用服务器的虚构数据。汇总的数字、各时区下 Date 的月份、createElement 和 createElementNS 的区别、占比的合计、打印时的显示和 A4 的 PDF(页数、横条和图表的有无),都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。打印的照片是把那个 PDF 转成图片后并排的。各页面的 HTML 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第13讲 — 汇总与打印 / 2026.10