Old-School Front-End / 2026 ・ No.12
古法前端入门
第12讲 审批流程 ── 用对话框确认,一个按钮推进状态
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第12讲是审批流程。
上一讲,古井的审批页面里出现了新井的纽约出差。可是没有可以按的按钮。
这次用 <dialog> 做一个查看申请内容的窗口,把批准・退回以 JSON 发给服务器。
然后实际制造一次两位审批人同时处理同一个申请的情况,看看会发生什么。
本讲的目标
新井前辈,纽约出差还没批吗?列表里不是已经有了吗?
古井有是有,可是没有能按的按钮。今天就做这个按钮。
新井放一个“批准”按钮而已嘛。交给 AI,3 秒搞定。
古井放按钮是 3 秒。麻烦的是按下去之后。按错了怎么办?退回的话理由呢?石田也按了同一个申请呢?
新井……石田是谁来着?
古井今天新来的另一位审批人。
当前位置:第12讲
- 先确认再按 ── 点列表里的“查看”打开 <dialog>,看过申请内容再“批准”或“退回”
- 退回要有理由 ── 备注为空就不发送。服务器也做同样的检查
- 同时按下也不会坏 ── 附上读取时的“版本”发送,被人抢先了就由服务器以 409 拒绝
- 给申请人看历史 ── 谁・什么时候・为什么退回,在新井的列表里就能打开
对面是在上一讲的练习用服务器上加了审批 API 和历史记录的版本。审批人除了古井 一郎,又加了石田 美咲([email protected] / demo-ishida)。两个都是仅限演示的虚构账号。被退回的申请修改后重新提交的流程,这一讲先省略。
申请的状态与箭头
做按钮之前,先把申请会经过哪些状态画成地图。申请的数据里一直都有 status(状态):草稿・审批中・已批准・已报销・已退回。这次要做的,是其中从审批中出发的两个箭头。
状态是方框,操作是箭头。这次做的是红色的两条
从这张地图,可以得出页面的两条规矩。
- 按钮只给“从当前状态出发的箭头” ── 待审批列表里只有审批中的申请,所以每一行都可以放“查看”。已批准的申请不显示“批准”
- 能不能走这个箭头,由服务器决定 ── 页面上没有按钮,也照样能发 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 件事
变暗靠的是 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 那一行)。用键盘操作的人不会被甩回页面开头
叠 div 自己做的模态框,“不让碰后面”“焦点移进来”“Esc 关闭”“回到原处”,全都得自己写。古法时代的人们为此哭过很多次。浏览器有专门的部件,就先用它。亲手写的,只是部件没有的部分。
发送批准・退回
按下“批准”或“退回”后,要告诉服务器。发送地址是 POST /api/expenses/EXP-2026-0012/decision。内容有三样:决定・备注・读取时的版本(第六节要用)。
第10讲用 FormData 发送了申请表单。这次不是表单,而是我们自己拼的一小份数据,所以转成 JSON 发送。这里有一个坑。
FormData 和 JSON,Content-Type 的处理正好相反
# 往 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 还显示“审批中”。如果古井这时把它退回……?
不加思考地做,服务器会用后来的“退回”覆盖掉。石田的批准就这样悄无声息地消失了。在新井看来,明明已经批准的申请,不知什么时候变成了退回。这可不行。
附上读取时的版本发送。对不上,服务器就拒绝
于是给申请的数据加上版本(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)。状态会改写,记录则不删除,一行一行往后加。
状态改写,历史追加
时间由服务器记录,因为每个人浏览器的时钟可能不准。服务器用 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() 拼的话,这些差别全得记住。这里也交给浏览器。
动手试试
启动练习用服务器,用古井・石田・新井三个浏览器,通过 Playwright 实际操作。古井的页面在石田批准之前就打开着,一直没刷新。
# 实际操作并记录的值(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 的历史,能读到古井退回的理由
审批流程中,页面要做的事并不多:先确认再让人按,不能按的就不让按,被拒绝时说清楚发生了什么。能不能推进状态、有没有被人抢先,决定的仍然是服务器。
结 — 一页总结与下讲预告
- 申请有状态。按钮只给从当前状态出发的箭头。能不能走,由服务器决定。
- 确认窗口用 <dialog> 和 showModal()。不让碰后面、焦点移进来、Esc 关闭、回到原处,浏览器都替你做。
- 列表重新生成,按过的按钮就没了。焦点去哪里要自己决定。
- 发 JSON 时自己写 Content-Type: application/json。和 FormData 正好相反。
- 附上版本发送,对不上就 409。被拒绝时,说明谁做了什么,再重新读取列表。
- 历史只追加不删除。时间由服务器记录,显示时用 toLocaleString。
新井纽约出差变成已批准了!46,851 日元,什么时候打到账上?
古井那得问财务。下一讲是汇总与打印。按月・按人算出合计,做成打印到纸上也好看的页面。是让财务高兴的一讲。
新井让财务高兴了,钱会不会打得快一点?
参考
- MDN — <dialog>: The Dialog element
- MDN — HTMLDialogElement: showModal() method
- MDN — ::backdrop
- W3C WAI-ARIA APG — Dialog (Modal) Pattern
- MDN — Using the Fetch API
- MDN — 409 Conflict
- MDN — HTTP response status codes (400, 403, 415)
- Martin Fowler — Optimistic Offline Lock
- MDN — Date.prototype.toLocaleString()
- MDN — Intl.DateTimeFormat() constructor
页面截图是笔者制作的演示页面,经由练习用服务器(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