Old-School Front-End / 2026 ・ No.13

古法前端入门

第13讲 汇总与打印 ── 按月相加,亲手画柱状图,再打印到纸上

发布

古井 一郎(审批人)的仪表盘。2026年7月〜10月。汇总的费用 282,081 日元・18 笔・待审批 11,900 日元。按月合计的柱状图是 7月 67,720、8月 57,660、9月 65,730、10月 90,971,右边是同样数字的表格。下面是按类别的明细(交通费 126,170 日元 44.7% 等,带红色横条)和按人合计(新井 太郎 9 笔 181,301 日元、佐藤 健 5 笔 53,900 日元、山田 花子 4 笔 46,880 日元)
本讲的目标。把已批准・已报销的申请按月・按类别・按人相加后显示的仪表盘(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际操作的画面。2026年10月19日拍摄,页面时钟固定在 2026年10月17日 15:00。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第13讲是汇总与打印。
菜单里一直有个“仪表盘”,可点进去什么都没有。
这次在那里放上按月・按类别・按人相加的表格,以及用 SVG 亲手画的柱状图。
然后让它能漂亮地打印到财务部长最爱的纸上。途中,还会踩到日期和时区的老坑。

一

本讲的目标

古井财务部长交代了:“把 7 月以来的费用按月汇总,打印成纸给我。”

新井纸……?不是 PDF?

古井是纸。听说要用红笔画圈。

新井那还得有图表吧。装个 Chart.js 一行就搞定。交给 AI 3 秒钟。

古井用库不是坏事。但今天亲手画。柱状图说到底,就是把长方形排起来而已。懂了里面的道理,库画出奇怪的图时你才能察觉。

当前位置:第13讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾
  • 定好计入哪些 ── 只加已批准・已报销的。待审批的另外显示
  • 分组相加 ── 按月・按类别・按人。都用同一个函数,只换生成键的方法
  • 亲手画柱状图 ── 用 JavaScript 排列 SVG 的长方形。数字也放进表格
  • 打印到纸上 ── 用打印用的 CSS 去掉菜单,加上日期时间和打印人,整理好分页

对面是在上一讲的练习用服务器上加了汇总用的 /api/report。数据也稍微多了一些。7〜9 月的部分,设定是古井以前用 Excel 台账管理、现在搬过来的(所以编号是 XL-2026-07-01 这样的形式)。申请人里还加入了佐藤 健([email protected] / demo-sato)。全部是仅限演示的虚构数据。

二

计入哪些

同样说“费用合计”,加哪些进去,数字就不一样。审批中的算不算?被退回的呢?草稿呢?写代码之前,先把这个定下来。

只加“要付钱・已付钱”的

已批准 已报销 审批中 草稿 已退回 计入汇总 要付钱・已经付钱的 待审批(另外显示) 不计 还没提交・等着重新提交 同样叫“合计”,放进去的东西不同,数字就不同。定好之后写在页面上 由谁决定?不是页面,而是和使用者(财务)的约定

这次和财务部长商量(设定如此)后,决定加已批准和已报销的。审批中的还不确定会不会付,所以不计入合计,作为“待审批”放在另一张卡片里。草稿和已退回的不计。这个约定起个名字,放在代码开头。

const COUNTED = ['approved', 'paid']; // 计入汇总的,只有要付钱(已付钱)的申请

能看到谁的申请,和第11讲一样由服务器决定。/api/report 给审批人返回所有人的申请,给申请人只返回自己的申请。页面只负责把收到的东西加起来。

三

分组相加

按月合计,就是“把申请按月分开,各自相加”。做一个分组的标记(键),往每个键对应的箱子里加。箱子用 Map。

生成键,加到同一个键的箱子里

例:4 笔申请 2026-07-01 1,980 2026-07-18 49,600 2026-08-06 1,240 2026-09-01 38,440 new Map() '2026-07' { count: 2, amount: 51,580 } '2026-08' { count: 1, amount: 1,240 } '2026-09' { count: 1, amount: 38,440 } '2026-10' 没有 → monthRange 补 0 ① 生成键:monthOf(c.date) 取前 7 个字符 ② 从 Map 取出来再加。没有的话从 { count: 0, amount: 0 } 开始 Map 里只有“出现过的键”。没有的月份会从图表上消失 按类别・按人也是同一个 sumBy,只是换个生成键的方法
/** 把 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'),在不同地方是不同的日期

UTC 6/30 7/1 7/1 0:00 东京 6/30 7/1 7/1 9:00 洛杉矶 6/30 7/1 6/30 17:00 new Date('2026-07-01') 只有日期的 '2026-07-01',按世界时(UTC)的0点读取 带时间的 '2026-07-01T00:00',则是当地的0点(容易混淆的地方) 只是按月分组的话,不要转成 Date。'2026-07-01'.slice(0, 7) 就够了

在仪表盘上试了一下。用洛杉矶时间的浏览器打开,把 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 向下增加

x y (0, 0) h y H − BOTTOM 原点在左上,y 朝下 和数学的坐标图上下相反 y = H − BOTTOM − h 柱子上端,往下画 h viewBox="0 0 640 240" 坐标范围,大小用 CSS 柱子的高度按“金额 ÷ 最大金额”来分配。最大的那个月是满高 元素要用 createElementNS 创建(第五节实测)
/** 创建 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 里。屏幕和纸需要的东西不一样。

纸上点不了的去掉,只有纸上需要的加上

屏幕 纸 CSS 页头(菜单・退出登录) d-print-none “打印”按钮 d-print-none 打印用的标题(谁・什么时候) d-print-block 图表的柱子(SVG 的 fill) fill 类别的横条(背景色) print-color-adjust 表格的行(分页处) break-inside 纸上点不了的东西去掉,只有纸上需要的(日期时间・打印人)加上 背景色在浏览器的默认设置下不打印(为了省墨)

去掉和加上,用 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 → 打印出来 / 没有 → 消失
把按 A4 输出成 PDF 的 3 页并排。第 1 页顶部是“报销报告 2026年7月 – 2026年10月 ・ 2026年10月17日 15:00 ・ 古井 一郎”的标题,没有页头菜单和打印按钮。下面是汇总卡片、按月的柱状图和表格。第 2 页是按类别的明细(带红色横条)、按人合计、计入汇总的申请表。第 3 页是申请表的后续,顶部又带上了表头
从“打印”到 A4。菜单消失,加上了日期时间和打印人的标题,表格跨页也带着表头(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际操作的画面。2026年10月19日拍摄,页面时钟固定在 2026年10月17日 15:00。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)

第 1 页是汇总和按月的图表,第 2 页是明细和申请列表,第 3 页是列表的后续。第 3 页的表格也再次带上了表头(表格的 thead 跨页时会重复打印)。按类别的明细在第 1 页下面放不下,因为 break-inside: avoid,整块被送到了第 2 页。另外,实际从浏览器打印时,浏览器有时会在页边加上 URL 和日期。这可以在打印界面的“页眉和页脚”里关掉。

八

动手试试

启动练习用服务器,用古井(审批人)和新井(申请人),通过 Playwright 实际打开。也试了洛杉矶时间的浏览器和不打印背景的 PDF。

3 个场景。1 新井 太郎(申请人)打开的仪表盘。汇总的费用 181,301 日元・9 笔・待审批 0 日元,按月是 7月 51,580、8月 5,200、9月 38,440、10月 86,081。没有按人合计的表。2 在洛杉矶时间、先把日期转成 Date 再取月时的按月合计。7月 87,740(4 笔)、8月 74,100(4 笔)、9月 27,290(3 笔)、10月 90,971(6 笔),可合计行仍是 18 笔 282,081。3 在不打印背景的设置下、去掉 print-color-adjust 后输出成 PDF 的按类别明细。数字和占比都在,红色横条消失了
①申请人打开时,②在洛杉矶时间转成 Date 时,③在不打印背景的设置下(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际操作的画面。2026年10月19日拍摄,页面时钟固定在 2026年10月17日 15:00。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)
# 是谁打开的(Chromium 实测)
 古井(审批人) 新井(申请人)
汇总的费用 282,081 日元 181,301 日元
笔数 18 笔 9 笔
待审批 11,900 日元 0 日元
按人合计的表 显示 不显示
计入汇总的申请 18 行 9 行
  • 同一个 dashboard.html,古井看到的是所有人的 18 笔,新井看到的是自己的 9 笔。新井的页面上没有按人合计的表(服务器也没有把别人的申请交给他)
  • 新井的待审批是 0 日元。因为上一讲 0012 和 0007 都被批准了
  • 在洛杉矶时间转成 Date 后,1 号的申请错到了前一个月,表的各行和合计行对不上了。按字符截取的版本,在哪个时区数字都一样
  • 在不打印背景的设置下,去掉 print-color-adjust 横条就消失,加上就留着
POINT

汇总页面最重要的,不是好看,而是数字对得上。定好计入哪些并写清楚。日期按字符处理。换个时区,确认表的各行和合计行是否对得上。图表是把这些数字画成图,打印时只留下读纸的人需要的东西。

结 — 一页总结与下讲预告

  1. 合计之前,先定计入哪些。这次是已批准・已报销。待审批另外显示。
  2. 分组相加用 Map 和键。月份列表用 monthRange 生成,0 的月份也不丢。
  3. 只是按月分组的话,日期不转成 Date,按字符截取。'2026-07-01' 会按世界时 0 点读取。
  4. SVG 元素用 createElementNS 创建。y 朝下。准确的数字也放进表格。
  5. 占比的尾数,加起来不一定是 100.0%。定好写法,加上注释。
  6. 打印用 @media print。用 d-print-none 隐藏,用 @page 和 break-inside 整理,背景色用 print-color-adjust。

新井把纸交给部长,7 月的福冈出差被画了红圈。问“这是什么”。

古井那是你的出差。去解释吧。……下一讲是 jQuery。把第8〜10讲亲手写的 DOM・事件・通信用 jQuery 重写,并排比较。古法中的古法。

新井就是满屏美元符号($)的那个吧。

参考

页面截图是笔者制作的演示页面,经由练习用服务器(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

← 返回文章列表