《Web-Dev-For-Beginners》实战:为银行应用实现「添加交易」对话框并接入既有状态管理系统
本篇技术指南围绕 7-bank-project/4-state-management/assignment.md 展开,讲解如何在已有的银行示例应用(Banking App)中,从零实现一个可用的「添加交易」(Add Transaction)对话框:从仪表盘入口按钮、模态框结构与无障碍设计,到表单校验、后端 API 对接,再到与既有状态管理系统(集中式状态、Object.freeze 不可变更新、localStorage 持久化)无缝集成,最终做到不刷新页面即时刷新余额与交易列表。完成本指南后,你将掌握一条从「静态页面」到「可写、可持久化、可无障碍访问」的前端功能闭环的开发路径,并可参照仓库中的参考实现逐一比对验收。
一、任务全景:串起银行应用四条主线的「收官练习」
在前序课程中,你的银行应用已经逐步构建起四块核心能力,而本作业要求你把这四块能力在同一功能上汇合:
| 课程模块 | 已具备能力 | 在本作业中的落点 |
|---|---|---|
| 模板与路由 | <template> + 前端路由(SPA 页面切换) |
决定「添加交易」用独立路由页面还是仪表盘内的模态框承载 |
| 表单处理 | 表单控件、提交拦截、输入校验与错误提示 | 收集日期、描述、金额并做提交前校验 |
| 数据与 API | 通过 fetch 对接后端账户数据 | 把表单 JSON 提交到交易接口并处理响应 |
| 状态管理 | 集中式 state、受控更新、持久化与数据刷新 |
新增交易后更新 state.account 并驱动仪表盘重绘 |
其核心学习目标可归纳为五条:创建易用的数据录入对话框界面、实现支持键盘与屏幕阅读器的无障碍表单、将新功能接入既有状态管理系统、练习 API 通信与错误处理、把现代 Web 开发模式应用到真实业务功能上。可见它并非单纯新增一个弹窗,而是要求你以「专业水准」把已有架构串成一个可用的真实功能。
下面以官方仓库的参考实现(7-bank-project/solution/app.js、7-bank-project/solution/index.html)为蓝本,按作业步骤逐层展开。
二、第 1 步:在仪表盘放置「添加交易」入口按钮
作业第一步要求为仪表盘添加一个用户容易发现、易于操作的按钮,并提出四个硬性要求:
- 位置合理:按钮应位于仪表盘逻辑上的显眼区域,通常紧挨交易列表标题;
- 文案清晰且带动作引导:直接使用 "Add transaction" 之类的动词短语,而非含糊的 "More" 或 "Edit";
- 视觉风格与既有 UI 一致:复用应用已有的按钮样式体系;
- 保证键盘可达:它是真实可聚焦的
<button>元素,而不是不可聚焦的<div>或图片。
仓库参考实现中,该按钮位于交易区块标题栏右侧,并复用了主按钮类 btn btn-primary:
<!-- 参考 7-bank-project/solution/index.html -->
<div class="transactions-title">
<h2 id="transactions-description">
<i class="fa-solid fa-receipt" aria-hidden="true"></i> Transactions
</h2>
<button class="btn btn-primary" type="button" onclick="addTransaction()">
<i class="fa-solid fa-plus" aria-hidden="true"></i> Add transaction
</button>
</div>
技术要点有二:
type="button"显式声明它不会触发任何表单提交,避免误把整个仪表盘页面当作表单处理;- 触发的是 app.js 中的
addTransaction()——它负责打开对话框并完成焦点迁移,这正是第 3 步无障碍实现的起点。
三、第 2 步:两种对话框实现方案对比
作业给出了两种实现路径,要求你自主选择其一:
方案 A:独立页面(Separate Page)
- 为交易表单新建一个 HTML 模板;
- 在既有路由表(
routes)中注册新路由; - 实现「进入表单页、保存后返回仪表盘」的双向导航。
该方案的优点是语义直接、便于分享 URL;缺点是需要额外的路由注册与状态传递,且每次往返都会打断用户在当前页面的上下文。
方案 B:模态对话框(Modal Dialog,作业推荐方案)
- 用 JavaScript 在仪表盘内直接控制对话框的显示/隐藏,不离开当前页面;
- 利用 HTML 全局
hidden属性或 CSS 类控制显隐; - 通过正确的焦点管理营造流畅的用户体验。
仓库参考实现采用方案 B,其显隐机制是「hidden 属性 + CSS 类」的组合:容器元素先以 hidden 隐藏,显示时添加 show 类覆盖默认样式(见 styles.css):
.dialog {
display: none; /* 默认隐藏 */
position: fixed;
inset: 0;
z-index: 1000;
}
.dialog.show { display: flex; } /* 打开时切换 display */
而 JS 侧(app.js)负责在打开时执行一组「入场准备」:表单 reset()、把日期默认设为今天(form.date.valueAsDate = new Date())、并立即把焦点移到首个输入框 form.date.focus(),同时注册点击遮罩与键盘事件监听。
为何建议方案 B?因为「添加交易」属于高频、小体量的录入动作,模态框让用户看到余额与交易列表保持可见,配合第 6 步的即时刷新体验最佳。
四、第 3 步:无障碍(Accessibility)实现——模态对话框达标的关键
作业明确要求对话框满足模态框无障碍标准,可从键盘导航与屏幕阅读器两个维度落实。
4.1 键盘导航三要素
- 支持 Escape 关闭对话框。参考实现的键盘处理(app.js)监听对话框上的
keydown,命中Escape即调用cancelTransaction():
function onDialogKeydown(e) {
if (e.key === 'Escape') {
cancelTransaction();
}
}
-
打开时把焦点"圈禁"在对话框内。本例通过把
keydown监听器挂在对话框容器上、并由后端 UI 内容只含表单字段来达成焦点不会自然逃逸到背景页面(更严格的做法是拦截 Tab 键循环焦点,可作进阶练习)。 -
关闭后把焦点还给触发按钮。这是最常见的被遗漏项——若不做处理,用户关闭弹窗后焦点会掉回
<body>,下一次键盘操作会从页面开头重新开始。参考实现的cancelTransaction()(app.js)在移除show类、解绑监听后,主动查询触发按钮并把焦点归还给它:
function cancelTransaction() {
const dialog = qs('transactionDialog');
if (!dialog) return;
dialog.classList.remove('show');
dialog.removeEventListener('keydown', onDialogKeydown);
const opener = document.querySelector('button[onclick="addTransaction()"]');
if (opener) opener.focus(); // 焦点回归触发器
}
4.2 屏幕阅读器支持
对话框容器需要完整的 ARIA 标注。参考实现的 HTML(7-bank-project/solution/index.html)是一个极佳的可对照模板:
<div class="dialog-content"
role="dialog"
aria-modal="true"
aria-labelledby="txDialogTitle"
aria-describedby="txDialogDesc">
<h2 id="txDialogTitle" class="text-center">Add transaction</h2>
<p id="txDialogDesc" class="muted text-center">
Use negative amount for debit and positive for credit; Esc closes…
</p>
<form id="transactionForm" action="javascript:void(0)" novalidate>
<label for="date">Date</label>
<input id="date" name="date" type="date" required>
<label for="object">Object</label>
<input id="object" name="object" type="text" maxlength="50" required>
<label for="amount">Amount (negative for debit)</label>
<input id="amount" name="amount" type="number" value="0" step="any"
inputmode="decimal" required>
<div id="transactionError" class="error" role="alert" aria-live="polite"></div>
...
</form>
</div>
要点逐一对应作业要求:
- ARIA 标签与角色:
role="dialog"声明模态语义;aria-modal="true"告知辅助技术背景内容不可交互;aria-labelledby指向可见标题;aria-describedby指向辅助说明文字; - 打开/关闭状态的播报:内容在打开瞬间进入无障碍树即被感知,且错误容器带
role="alert"与aria-live="polite",提交失败时校验消息会被自动读出; - 清晰的字段标签与错误消息:每个输入框都有与之配对的
<label for>,错误提示(如 "Amount must be a valid number")写入#transactionError并调用.focus()让读屏立刻聚焦(见 app.js)。
五、第 4 步:构建收集交易数据的表单
表单是对话框的核心。作业规定三个必填字段:
| 字段 | 含义 | 输入类型 | 业务约束 |
|---|---|---|---|
| 日期(Date) | 交易发生的日期 | type="date" |
必填;实现中默认填充今天 |
| 描述/对象(Description/Object) | 这笔交易是什么 | type="text"(maxlength="50") |
必填,如 "Bought book" |
| 金额(Amount) | 交易数值 | type="number"(step="any") |
收入为正数、支出为负数(如 -20) |
术语说明:界面与文档常称第三个字段为"描述(description)",而仓库 API 与参考表单统一使用字段名
object(见上文 HTMLname="object"),两者指同一概念,对接时以 JSON 键object为准。
表单还要求具备四项特征:
- 提交前校验用户输入。参考实现(app.js)在
confirmTransaction()里做内联校验:金额必须能转成有限数值(Number.isFinite(amountVal)),对象描述不得为空,任一不满足即写入错误区并提前return,不会发起网络请求; - 对非法数据给出清晰错误消息,如
Amount must be a valid number、Object is required,并让错误区域获得焦点以便读屏播报; - 包含辅助性占位文本与标签——示例中金额标签就写明 "negative for debit",甚至帮助文案里再次提示
Use negative amount for debit and positive for credit; - 与既有设计体系一致——按钮复用
btn btn-primary/btn-ghost,输入框、标签、错误提示均来自全局样式表。
另外注意表单使用 novalidate 关闭浏览器原生气泡,改为自己控制校验时机与消息展示,这正是第 5 步做统一错误处理的前提。
六、第 5 步:API 集成——把表单数据送到后端
6.1 先读 API 规格
作业要求先查阅 服务端 API 文档 确认正确的端点与数据格式。结合该文档与后端实现 server.js,新增交易的关键契约如下:
| 项 | 规格 |
|---|---|
| 端点 | POST /api/accounts/:user/transactions |
| 请求体(JSON) | { "date": "2020-10-05", "object": "Bought book", "amount": -20 } |
| 成功响应 | 201 Created,返回含服务端生成的 id 的完整交易对象 |
| 400 | 缺少 date/object/amount,或 amount 非法(Missing parameters / Amount must be a number) |
| 404 | 账户不存在(User does not exist) |
| 409 | 重复交易——后端以 md5(date + object + amount) 生成 id 并查重(Transaction already exists) |
后端行为需留意两点:交易会被 push 进 account.transactions,同时余额自动累加(account.balance += transaction.amount),因此前端只需以「追加后余额与交易」刷新本地账户对象即可保持一致。此外 server.js 的数据全部存于内存(含内置的 test 账户),服务重启即清空,属教学环境预期。
仓库还提供了可直接在 VS Code REST Client 中发送的示例请求(7-bank-project/api/api.http):
POST http://localhost:5000/api/accounts/sinedied/transactions
Content-Type: application/json
{
"date": "2020-07-24",
"object": "Bought book",
"amount": -20
}
本地启动方式:在 7-bank-project/api 目录执行 npm install 后运行 npm start,服务默认监听 5000 端口(与前端应用所在的 3000 端口并存,勿关闭)。
6.2 前端提交链路
参考实现的提交逻辑把「序列化 → 请求 → 错误处理」浓缩为几行(app.js):
const jsonData = JSON.stringify(Object.fromEntries(new FormData(form)));
const data = await createTransaction(state.account.user, jsonData);
if (data.error) {
setFormError('transactionError', data.error);
return;
}
- 构造 JSON:
new FormData(form)依表单控件的name(date/object/amount)收集数据,Object.fromEntries转为对象,JSON.stringify序列化后发送——不需要手写键值映射,也避免了遗漏新字段; - 错误处理:任何失败(校验、账户不存在、网络中断等)统一通过
setFormError写入表单错误区并聚焦,实现"一处统一提示"; - 成功/失败反馈:成功后进入第 6 步的状态更新流程,失败则停留在对话框内让用户原地修正。
教学版前端用一个内存 mock 模块模拟了异步 API(solution/app.js 中的
createTransaction),它对真实网络环境的意义在于:请求与 UI 解耦,后续把serverUrl指向真实后端时,对话框层代码无需改动。
七、第 6 步:状态管理集成——不刷新页面即时更新仪表盘
这是整个作业的"点睛"步骤。作业要求:添加成功后刷新账户数据、不刷新页面更新仪表盘、新交易立即可见、全程保持状态一致。其实现基础是课程第 4 部分打造的集中式不可变状态(详见 7-bank-project/4-state-management/README.md):所有变更都必须经由唯一的 updateState(),每次变更产生一个用 Object.freeze() 锁定的新状态对象,并自动把当前账户写入 localStorage:
// 参考实现(solution/app.js)
let state = Object.freeze({ account: null });
function updateState(property, newData) {
state = Object.freeze({ ...state, [property]: newData });
localStorage.setItem(storageKey, JSON.stringify(state.account));
}
「添加交易」与状态系统的对接因此在 confirmTransaction() 中完成(app.js):
// 用响应数据构造新的账户对象(余额累加、交易追加),再整体更新状态
const newAccount = {
...state.account,
balance: (Number(state.account.balance) || 0) + data.amount,
transactions: [...(state.account.transactions || []), data]
};
updateState('account', newAccount);
// 关闭对话框并重绘仪表盘
cancelTransaction();
updateDashboard();
这一步体现三条原则:
- 不直接改旧对象:新对象基于展开运算符派生,旧状态在更新前保持完整,天然支持未来做撤销(undo)等历史追溯功能;
- 单一更新入口:余额、交易列表的变更与 localStorage 持久化都由
updateState()一次完成,不存在"改了页面忘了存盘"的窗口期; - 重绘与数据分离:
updateDashboard()(app.js)只负责"读state.account→ 渲染",它把交易按日期倒序排列,负数为支出、正数为收入分别加debit/credit样式类;由于状态是唯一数据源,任何写入路径(本作业新增的对话框、其它标签页的storage事件等)都能得到一致的呈现。
整个数据流向可概括为下图所示的闭环:用户动作 → 事件处理 → 受控状态更新 → 视图重绘,这正是集中式状态管理解决"多个函数各改各的数据"问题的关键架构。
八、实现效果与验收对照
完成六个步骤后,你的银行应用将具备一个观感与行为都接近专业产品的完整功能:点击仪表盘上的 Add transaction 弹出模态框,录入日期、对象与金额(负数表示支出),确认后对话框关闭、余额与交易列表立刻刷新,全程无需重载页面。
功能测试清单
- 确认「Add transaction」按钮清晰可见且可访问;
- 验证对话框能够正确打开与关闭(含 Cancel、遮罩点击、Escape 三种关闭路径);
- 确认表单校验覆盖全部必填字段并展示明确错误;
- 验证添加成功后新交易立即出现在仪表盘交易列表中;
- 确认对非法数据与网络异常都能给出恰当的失败处理。
无障碍测试清单
- 仅用键盘完整走通「打开 → 录入 → 提交/取消」全流程;
- 用屏幕阅读器验证开合播报与错误提示能被正确朗读;
- 验证焦点管理(打开进对话框、Escape/取消后回到触发按钮);
- 检查所有表单元素都带正确的
<label>。
九、评估标准速览
作业附带的评估矩阵可作为自检清单,也可作为评审他人实现的标尺:
| 维度 | 优秀(Exemplary) | 合格(Adequate) | 待改进(Needs Improvement) |
|---|---|---|---|
| 功能性 | 功能无瑕疵、体验出色,遵守课程全部最佳实践 | 功能正确但个别最佳实践未落实或有轻微可用性问题 | 功能部分可用或存在明显可用性缺陷 |
| 代码质量 | 组织清晰、遵循既有模式、含错误处理、与状态管理无缝衔接 | 可用但组织欠佳或与既有代码风格不一致 | 结构性缺陷明显、未与既有模式良好集成 |
| 无障碍 | 完整键盘导航、读屏兼容、符合 WCAG 且焦点管理出色 | 有基础无障碍但缺失部分键盘/读屏能力 | 几乎没有考虑无障碍 |
| 用户体验 | 界面直觉化、打磨到位、反馈清晰、交互流畅、观感专业 | 体验尚可但反馈或视觉尚有提升空间 | 界面令人困惑或缺少用户反馈 |
十、进阶挑战(可选)
在基础功能完成后,作业还给出两组加分方向,用于练习更高级的 Web 开发概念:
- 增强功能:为交易增加分类(餐饮、交通、娱乐等);实现带实时反馈的输入校验;为熟练用户提供键盘快捷键;支持交易的编辑与删除。
- 高级集成:为最近添加的交易实现 Undo(撤销);支持从 CSV 批量导入;实现交易搜索与筛选;增加数据导出能力。
值得注意的是,课程主线状态管理部分(7-bank-project/4-state-management/README.md 的进阶挑战)恰好要求为状态系统加入「历史数组 + 撤销/重做」,与这里「Undo 最近添加交易」的挑战互相呼应——若两者结合,你实际上是在把对话框写入路径与状态历史快照打通,这已经触及生产级应用常见的时间旅行调试(time-travel)思路。
小结
本文从「任务全景」出发,按六步实施路径拆解了「添加交易」对话框的完整实现:入口按钮与模态框结构、可对照 ARIA 标准的无障碍细节、三字段表单与提交前校验、以上下文无关的 JSON 对接后端交易接口,最终经由 updateState() 把新交易并入集中式状态并驱动仪表盘即时重绘。官方参考实现全部收于 7-bank-project/solution(HTML 模板见 index.html、逻辑见 app.js、样式见 styles.css),可随时逐行对照学习。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00

