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

古法前端入门

第12讲 审批流程 ── 用对话框确认,一个按钮推进状态

发布

古井 一郎(审批人)的审批页面。待审批的表格上方打开了“查看申请 EXP-2026-0012”的对话框,后面的页面变暗。对话框里是申请人 新井 太郎、申请日期 2026-10-17、事由 纽约出差(10月13日〜16日)、金额 46,851 日元。备注栏里已填好“纽约出差的内容已确认。”,下面是关闭・退回・批准三个按钮
本讲的目标。用 <dialog> 查看申请内容,附上备注后批准(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第12讲是审批流程。
上一讲,古井的审批页面里出现了新井的纽约出差。可是没有可以按的按钮。
这次用 <dialog> 做一个查看申请内容的窗口,把批准・退回以 JSON 发给服务器。
然后实际制造一次两位审批人同时处理同一个申请的情况,看看会发生什么。

一

本讲的目标

新井前辈,纽约出差还没批吗?列表里不是已经有了吗?

古井有是有,可是没有能按的按钮。今天就做这个按钮。

新井放一个“批准”按钮而已嘛。交给 AI,3 秒搞定。

古井放按钮是 3 秒。麻烦的是按下去之后。按错了怎么办?退回的话理由呢?石田也按了同一个申请呢?

新井……石田是谁来着?

古井今天新来的另一位审批人。

当前位置:第12讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾
  • 先确认再按 ── 点列表里的“查看”打开 <dialog>,看过申请内容再“批准”或“退回”
  • 退回要有理由 ── 备注为空就不发送。服务器也做同样的检查
  • 同时按下也不会坏 ── 附上读取时的“版本”发送,被人抢先了就由服务器以 409 拒绝
  • 给申请人看历史 ── 谁・什么时候・为什么退回,在新井的列表里就能打开

对面是在上一讲的练习用服务器上加了审批 API 和历史记录的版本。审批人除了古井 一郎,又加了石田 美咲([email protected] / demo-ishida)。两个都是仅限演示的虚构账号。被退回的申请修改后重新提交的流程,这一讲先省略。

二

申请的状态与箭头

做按钮之前,先把申请会经过哪些状态画成地图。申请的数据里一直都有 status(状态):草稿・审批中・已批准・已报销・已退回。这次要做的,是其中从审批中出发的两个箭头。

状态是方框,操作是箭头。这次做的是红色的两条

草稿 draft 审批中 submitted 已批准 approved 已报销 paid 提交 新井 批准 古井 报销 财务 已退回 returned 退回 古井・必须写理由 修改后重新提交 (本讲省略) 按钮只给“从当前状态能走的箭头”(只有审批中的申请才有“查看”) 能不能沿着箭头走,由服务器决定(看当前状态和是谁按的)

从这张地图,可以得出页面的两条规矩。

  • 按钮只给“从当前状态出发的箭头” ── 待审批列表里只有审批中的申请,所以每一行都可以放“查看”。已批准的申请不显示“批准”
  • 能不能走这个箭头,由服务器决定 ── 页面上没有按钮,也照样能发 fetch(第11讲的“隐藏和拒绝是两回事”)。服务器看“当前状态”和“按的人”,决定放行还是拒绝

新井已报销之后,就不能“还是退回吧”了啊。

古井钱都打出去了再退回,财务要哭的。没有箭头,就是这个意思。

三

用 <dialog> 做确认窗口

按下“查看”后,在页面上方打开一个小窗口,显示申请内容、备注栏和按钮。这种窗口叫模态框。以前都是叠一个 div 自己做,现在 HTML 有专门的元素:<dialog>。

<dialog id="decide" aria-labelledby="decide-h">
  <h2 id="decide-h">查看申请 <span id="d-no"></span></h2>
  <dl>
    <dt>申请人</dt><dd id="d-who"></dd>
    <dt>申请日期</dt><dd id="d-date"></dd>
    <dt>事由</dt><dd id="d-title"></dd>
    <dt>金额</dt><dd id="d-amount"></dd>
  </dl>
  <label for="decide-comment">备注(退回时必填)</label>
  <textarea id="decide-comment" rows="3"></textarea>
  <p id="decide-error" role="alert" hidden></p>
  <div>
    <button type="button" id="d-close">关闭</button>
    <button type="button" id="d-return">退回</button>
    <button type="button" id="d-approve">批准</button>
  </div>
</dialog>

里面是标题・描述列表(dl)・label 和 textarea・按钮,全是普通的 HTML。按钮都不提交表单,所以是 type="button"。错误栏和第9讲一样设成 role="alert",出现时会被读出来。

打开它靠 JavaScript。列表的按钮每一行都会重新生成,所以用第8讲的事件委托,让 tbody 统一接收点击。

// 按钮在 tbody 上接收(第8讲的事件委托)
document.querySelector('#approvals').addEventListener('click', (event) => {
  const button = event.target.closest('button[data-id]');
  if (button) openDecision(claims.find((x) => x.id === button.dataset.id));
});

/** 把申请放进对话框并打开 */
function openDecision(claim) {
  current = claim;
  document.querySelector('#d-no').textContent = claim.id;
  document.querySelector('#d-who').textContent = claim.ownerName;
  document.querySelector('#d-date').textContent = claim.date;
  document.querySelector('#d-title').textContent = claim.title;
  document.querySelector('#d-amount').textContent = formatYen(claim.amount);
  comment.value = '';
  decideError.hidden = true;
  dialog.showModal(); // showModal:后面的页面不能操作
}

对话框有两种打开方式。show() 只是显示出来。showModal() 则作为模态框打开,浏览器会替你承担好几件麻烦事。

只要调用 showModal(),浏览器就替你做的 4 件事

1 后面碰不到 变暗,点击和 Tab 都到不了 2 焦点移进来 选中第一个输入框(备注栏) 3 按 Esc 关闭 关闭按钮要自己做(close()) 4 关闭后回到原按钮 用键盘的人不会迷路 以上全部,只要调用 showModal(),浏览器就替你做了(实测见第四节) 自己做模态框(叠一个 div)的话,这四件事都得自己写

变暗靠的是 CSS 的 ::backdrop。外观按 Bootstrap 的风格,去掉边框、圆角、加阴影而已。

.app-dialog {
  width: min(32rem, calc(100% - 2rem));
  border: 0;
  border-radius: .5rem;
  padding: 1.5rem;
  box-shadow: 0 1rem 3rem rgba(0, 0, 0, .25);
}
.app-dialog::backdrop {  /* 让后面的页面变暗的幕布 */
  background: rgba(20, 30, 40, .45);
}
四

试一试对话框

说是“替你做”,是真的吗?在 Chromium 里打开,用 Playwright 代替键盘和鼠标戳了戳。

# <dialog> 的行为(Chromium 实测)
做了什么 结果
用 showModal() 打开 焦点落在备注栏(textarea)
查看“退出登录”按钮的位置 点到的是 DIALOG。碰不到后面的页面
按 Esc 键 对话框关闭了(open = false)
关闭之后的焦点 回到了打开它的按钮(EXP-2026-0007 的“查看”)
不写理由就“退回” “退回时,请在备注里写明理由。”
  • 一打开,焦点就在备注栏。马上就能开始写
  • 用 document.elementFromPoint() 查页头“退出登录”的位置,点到的是对话框(后面的幕布)。后面的按钮点不到
  • 按 Esc 关闭,焦点回到了打开时的“查看”(0007 那一行)。用键盘操作的人不会被甩回页面开头
POINT

叠 div 自己做的模态框,“不让碰后面”“焦点移进来”“Esc 关闭”“回到原处”,全都得自己写。古法时代的人们为此哭过很多次。浏览器有专门的部件,就先用它。亲手写的,只是部件没有的部分。

五

发送批准・退回

按下“批准”或“退回”后,要告诉服务器。发送地址是 POST /api/expenses/EXP-2026-0012/decision。内容有三样:决定・备注・读取时的版本(第六节要用)。

第10讲用 FormData 发送了申请表单。这次不是表单,而是我们自己拼的一小份数据,所以转成 JSON 发送。这里有一个坑。

FormData 和 JSON,Content-Type 的处理正好相反

new FormData(form) 第10讲 自动 Content-Type: multipart/form-data; boundary=… 分隔标记也是自动的。自己写反而会坏(第10讲) JSON.stringify({…}) 这一讲 自动 Content-Type: text/plain;charset=UTF-8 被当作普通文本发送。服务器以 415 拒绝(实测) 发 JSON 时要自己写 Content-Type(和 FormData 正好相反) headers: { 'Content-Type': 'application/json' }
# 往 body 里放什么,Content-Type 会变成什么(Chromium 实测)
放进 body 的东西 浏览器加上的 Content-Type
JSON.stringify(…)(只是字符串) text/plain;charset=UTF-8
 → 练习用服务器以 415 拒绝了(只收 JSON)
new FormData(…)(第10回) multipart/form-data; boundary=…

第10讲说的是“不要自己写 Content-Type”,因为分隔标记(boundary)由浏览器决定。JSON 正相反:JSON.stringify() 生成的只是字符串,浏览器会加上 text/plain。想告诉对方这是 JSON,就得自己写。

/** 把批准・退回发送到服务器 */
async function decide(decision) {
  decideError.hidden = true;
  if (decision === 'return' && comment.value.trim() === '') { // 退回必须写理由(服务器也做同样的检查)
    decideError.textContent = '退回时,请在备注里写明理由。';
    decideError.hidden = false;
    comment.focus();
    return;
  }
  const buttons = dialog.querySelectorAll('button');
  for (const b of buttons) b.disabled = true;
  try {
    const res = await fetch(`/api/expenses/${current.id}/decision`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' }, // 发送 JSON 时要自己写 Content-Type(和 FormData 相反)
      body: JSON.stringify({ decision, comment: comment.value, version: current.version }), // 读取时的版本。别人先改了就是 409
    });
    const no = current.id.slice(-4);
    if (res.status === 409) { // 409 = 别人先处理了
      const body = await res.json();
      const by = body.by, what = WHAT[body.status];
      dialog.close();
      pageStatus.className = 'alert alert-warning';
      pageStatus.textContent = `${no} 已由${by}先行${what}。列表已刷新。`;
      await loadApprovals();
      pageStatus.focus(); // 按过的按钮随列表一起消失了,所以把焦点移到结果提示上
      return;
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    dialog.close();
    pageStatus.className = 'alert alert-success';
    pageStatus.textContent = decision === 'approve' ? `已批准 ${no}。` : `已退回 ${no}。`;
    await loadApprovals();
    pageStatus.focus(); // 按过的按钮随列表一起消失了,所以把焦点移到结果提示上
  } catch (error) {
    decideError.textContent = '没能发送。请确认网络状态后再试一次。';
    decideError.hidden = false;
  } finally {
    for (const b of buttons) b.disabled = false; // 不管成功还是失败,都把按钮恢复原样
  }
}
  • 退回时理由为空,就不发送,在对话框里提示(和第9讲的输入检查是同一个思路)
  • 发送期间,把对话框的按钮全部设为 disabled,防止连按发两次(第10讲)。在 finally 里一定恢复
  • 成功的话,关闭对话框,用绿色横条提示,重新读取列表

最后那句 pageStatus.focus(),是试的时候发现的。一开始没有这一行。批准之后列表重新生成,按过的“查看”按钮连同表格一起消失。对话框想把焦点还给打开它的按钮,可那个按钮已经不在了。一查,焦点掉到了 BODY,也就是页面开头。用键盘操作的人就不知道自己在哪里了。于是把焦点移到结果提示(<p id="page-status" role="status" tabindex="-1">)上。改好之后,确认焦点在 page-status。

新井用鼠标的话,一辈子都发现不了吧。

古井所以要用 Tab 键至少走一遍。这是古法的规矩。

六

两个人同时按下的话

审批人变成两个了。古井开着审批页面去泡茶。这期间,石田批准了 0007。古井回来时,屏幕上的 0007 还显示“审批中”。如果古井这时把它退回……?

不加思考地做,服务器会用后来的“退回”覆盖掉。石田的批准就这样悄无声息地消失了。在新井看来,明明已经批准的申请,不知什么时候变成了退回。这可不行。

附上读取时的版本发送。对不上,服务器就拒绝

古井 服务器 石田 0007 · submitted · version 1 GET /api/approvals GET /api/approvals version: 1 version: 1 approve, version: 1 200 version → 2 return, version: 1 409 Conflict by: 石田 美咲 0007 · approved · version 2 “已由石田先行批准” 发送时附上“读取时的版本”。和当前版本不同,就说明有人先改了 服务器不覆盖,而是以 409 拒绝。页面说明原因,并重新读取列表

于是给申请的数据加上版本(version),每改一次加 1。页面记住读取列表时的版本,发送时附上。服务器发现收到的版本和当前版本不同,就知道“有人先改了”,不覆盖,而是以 409 Conflict 拒绝。回复里放上当前状态和是谁改的。

这种做法叫乐观锁。乐观地认为“大概不会和谁撞上”,只在撞上时拒绝。也有一打开就上锁、让别人等着的做法(悲观锁),可要是有人去泡茶了,所有人都得等。

往服务器发了各种请求试试。

# 往审批 API 发各种请求(以古井身份。用 Chromium 的 fetch 实测)
发送的内容 状态码 服务器的回复
JSON 字符串,没有 Content-Type 415 send JSON
{decision: approve}(不是 JSON) 400 bad JSON
decision 是 maybe 400 bad decision
退回,备注只有空格 400 a reason is required
版本旧了(version 0) 409 changed by someone else
批准(version 1) 200 status: approved, version: 2
再发一次同样的内容 409 changed by someone else
申请人(新井)来发 403 approvers only
  • 没有 Content-Type 是 415,读不成 JSON 是 400,决定不对是 400,理由只有空格也是 400。就算绕过了页面的检查,也会在服务器上被拦下
  • 版本旧了是 409。同样的内容发两次,第二次也是 409(因为版本已经升了)。顺便也防住了连按
  • 申请人新井来发,是 403(第11讲)

收到 409 时,页面关闭对话框,用黄色横条提示“0007 已由石田 美咲先行批准。列表已刷新。”,然后重新读取列表。诀窍是说清楚谁、做了什么。只写“发生错误”的话,古井还会再按一次。

新井泡杯茶的工夫,世界就变了啊。

古井屏幕上显示的,是读取那一刻的世界,不是现在的世界。别忘了这一点。

七

留下历史并展示

被退回的新井最想知道的是“为什么?”。所以每个申请都留下事件记录(history)。状态会改写,记录则不删除,一行一行往后加。

状态改写,历史追加

申请(当前状态) id: 'EXP-2026-0005' status: 'returned' version: 2 history: [ … ] 改写 history(事件记录) at: …T09:30… submitted 新井 at: …T16:40… returned 古井 “收据图片被截断了。…” 下一件事追加在这里 时间由服务器记录(不依赖浏览器的时钟) 显示时换成日本时间,并用该语言的写法 只追加不删除,所以事后能追查“谁、什么时候、为什么”

时间由服务器记录,因为每个人浏览器的时钟可能不准。服务器用 2026-10-17T01:06:00.000Z(日本时间 10:06)这样带时差的通用写法(ISO 8601)保存,页面显示时再换成日本时间,并用该语言的写法。

/** 时间用日本时间,按该语言的写法显示 */
function formatTime(iso) {
  return new Date(iso).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo', dateStyle: 'medium', timeStyle: 'short' });
}

/** 用对话框显示申请的历史 */
async function openHistory(id) {
  const res = await fetch(`/api/expenses/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const claim = await res.json();
  document.querySelector('#h-claim').textContent = `${claim.id}  ${claim.title}`;
  const items = claim.history.map((h) => {
    const li = document.createElement('li');
    li.className = 'mb-2';
    const head = document.createElement('div');
    head.textContent = `${formatTime(h.at)}  ${ACT[h.action]}  ${h.byName}`;
    li.append(head);
    if (h.comment) {
      const note = document.createElement('div');
      note.className = 'small text-body-secondary';
      note.textContent = h.comment;
      li.append(note);
    }
    return li;
  });
  document.querySelector('#h-list').replaceChildren(...items);
  historyDialog.showModal();
}

备注是审批人写的,不是新井写的,所以不用 innerHTML,而用 textContent 放进去(第8讲的 XSS)。因为指定了 timeZone: 'Asia/Tokyo',不管电脑设成哪个国家的时间,都显示日本时间。

# 同一个时刻,用三种语言(toLocaleString。Chromium 实测)
语言 0012 的批准 0005 的提交
ja-JP 2026/10/17 10:06 2026/10/09 9:30
zh-CN 2026年10月17日 10:06 2026年10月9日 09:30
en-US Oct 17, 2026, 10:06 AM Oct 9, 2026, 9:30 AM

同一个时刻,不同语言的写法不一样。日语小时前面不补 0,写“9:30”;中文写“09:30”;英语加上上午・下午,写“9:30 AM”。自己用 getHours() 拼的话,这些差别全得记住。这里也交给浏览器。

新井 太郎(申请人)的申请列表。本月汇总为申请 5 笔・待审批金额 0 日元・被退回 1 笔。表格依次是 0012 纽约出差 46,851 日元 已批准、0007 大阪出差 32,400 日元 已批准、0006 拜访客户的电车费 3,830 日元 已批准、0005 购买技术书 4,180 日元 已退回、0004 学习会参加费 3,000 日元 已报销。每一行都有“历史”按钮
审批结束后新井的申请列表。每一行都能打开“历史”(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)
八

动手试试

启动练习用服务器,用古井・石田・新井三个浏览器,通过 Playwright 实际操作。古井的页面在石田批准之前就打开着,一直没刷新。

3 个场景。1 不写备注就按“退回”,对话框里显示“退回时,请在备注里写明理由。”。2 石田先批准了 0007 之后,古井在旧列表上想退回 0007,黄色横条显示“0007 已由石田 美咲先行批准。列表已刷新。”,表格里只剩 0010。3 新井打开 0005 的历史,显示 2026年10月9日 09:30 提交 新井 太郎、2026年10月9日 16:40 退回 古井 一郎,下面是理由“收据图片被截断了。请重新拍摄后附上。”
①不写理由就退回,②两位审批人抢同一个申请,③申请人打开历史(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)
# 实际操作并记录的值(Chromium。服务器是练习用的)
谁 做了什么 页面上显示的
古井 打开审批页面 待审批 3 条:0007, 0010, 0012
古井 不写理由就退回 0012 退回时,请在备注里写明理由。
古井 批准 0012 已批准 0012。(剩 2 条)
石田 批准 0007 已批准 0007。(剩 1 条)
古井 (列表还是旧的)退回 0007 0007 已由石田 美咲先行批准。(409)
新井 打开申请列表 0012 已批准 / 0007 已批准 / 0006 已批准 / 0005 已退回 …
新井 打开 0005 的历史 2 行。最后一行是“2026年10月9日 16:40 退回 古井 一郎”
  • 不写理由的退回,在对话框里就被拦下。什么都没有发给服务器
  • 古井批准 0012 后,列表剩 2 条。石田批准 0007 后,石田的列表剩 1 条
  • 古井在旧列表上退回 0007,服务器以 409 拒绝,并带着石田的名字提示。列表重新读取后只剩 0010
  • 新井的列表里,0012 和 0007 是已批准。打开 0005 的历史,能读到古井退回的理由
POINT

审批流程中,页面要做的事并不多:先确认再让人按,不能按的就不让按,被拒绝时说清楚发生了什么。能不能推进状态、有没有被人抢先,决定的仍然是服务器。

结 — 一页总结与下讲预告

  1. 申请有状态。按钮只给从当前状态出发的箭头。能不能走,由服务器决定。
  2. 确认窗口用 <dialog> 和 showModal()。不让碰后面、焦点移进来、Esc 关闭、回到原处,浏览器都替你做。
  3. 列表重新生成,按过的按钮就没了。焦点去哪里要自己决定。
  4. 发 JSON 时自己写 Content-Type: application/json。和 FormData 正好相反。
  5. 附上版本发送,对不上就 409。被拒绝时,说明谁做了什么,再重新读取列表。
  6. 历史只追加不删除。时间由服务器记录,显示时用 toLocaleString。

新井纽约出差变成已批准了!46,851 日元,什么时候打到账上?

古井那得问财务。下一讲是汇总与打印。按月・按人算出合计,做成打印到纸上也好看的页面。是让财务高兴的一讲。

新井让财务高兴了,钱会不会打得快一点?

参考

页面截图是笔者制作的演示页面,经由练习用服务器(Node.js),于2026年10月17日在云端的 Chromium(Playwright)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。账号・申请・备注都是练习用服务器的虚构数据。对话框的行为、页面上显示的提示、状态码、Content-Type、历史时间的写法、焦点的位置,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。各页面的 HTML 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。

古法前端入门 第12讲 — 审批流程 / 2026.10

← 返回文章列表