Web-Dev-For-Beginners 银行应用实战:基于状态管理实现 "Add Transaction" 交易录入对话框
【免费下载链接】Web-Dev-For-Beginners24 Lessons, 12 Weeks, Get Started as a Web Developer项目地址: https://gitcode.com/GitHub_Trending/we/Web-Dev-For-Beginners
导读
本篇文章以 Web-Dev-For-Beginners 课程第 7 章「银行应用(Bank Project)」第 4 节《状态管理(State Management)》的课后作业为核心,完整讲解如何在已有集中式状态管理与 localStorage 持久化系统之上,为仪表盘(Dashboard)实现一个专业、可无障碍访问的"添加交易(Add Transaction)"对话框。读完本文,你将掌握模态对话框的两种实现路径(独立页面 vs 弹窗)、完整的数据校验与错误处理、与 Express API 的对接方式,以及如何让新交易在不刷新页面的情况下立即出现在仪表盘上——所有内容均有本仓库源码(7-bank-project/solution/)与 API 服务(7-bank-project/api/)作为事实依据。
1. 任务背景:本作业在前四节课程中的定位
本作业将前四节银行应用课程所学的内容串联起来,它们分别是:
| 课程小节 | 核心知识点 | 与本作业的关系 |
|---|---|---|
| 1. 模板与路由 | HTML<template>模板、SPA 路由 | 对话框 UI 以模板形式挂在 Dashboard 模板内部 |
| 2. 表单处理 | 表单校验、FormData、提交拦截 | 交易表单的收集、校验与提交逻辑 |
| 3. 数据获取 | fetch/异步请求、账户数据加载 | 提交后刷新账户数据、错误处理 |
| 4. 状态管理 | 集中式 state、Object.freeze、localStorage 持久化 | 新交易通过updateState()写入状态并持久化 |
作业的完成目标非常明确:在不破坏已有状态管理系统的前提下,让用户能够自助录入交易。也就是说,你要新增的功能必须「长在」现有的updateState()/state/ 路由体系之上,而不是另起炉灶。
前置条件:请先确保已完成 7-bank-project/3-data 的数据获取课程,并按 7-bank-project/api/README.md 启动 API 服务。验证方式:在终端执行
curl http://localhost:5000/api,返回Bank API v1.0.0即表示服务正常(API 默认监听 5000 端口,与主应用所在端口相互独立,请勿关闭)。
2. Step 1:添加交易按钮(Add Transaction Button)
作业要求先在仪表盘页面上放置一个用户易于发现和访问的「Add Transaction」按钮,并给出四项硬性要求:
- 放置在仪表盘的逻辑合理位置(通常位于交易列表标题区右侧);
- 使用清晰、面向操作的按钮文案;
- 按钮样式与现有 UI 设计保持一致;
- 按钮支持键盘访问(原生
<button>天然满足)。
本仓库的解决方案在 7-bank-project/solution/index.html 的 Dashboard 模板中给出了标准做法——把按钮放在交易列表标题h2旁边:
<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>设计要点拆解:
- 按钮语义:使用
<button type="button">而非<a>或<div>,保证 Tab 键可聚焦、回车/空格可触发; - 点击行为:
onclick="addTransaction()"直接调用对话框的打开函数(这是作业 Option B「模态对话框」的入口); - 样式一致性:复用全局
.btn .btn-primary类,与登录页、注册页按钮同源; - 图标辅助:
<i>图标均带aria-hidden="true",避免屏幕阅读器朗读装饰性图标。
从 7-bank-project/solution/app.js 可以看到,addTransaction()做的事很简单——给对话框加show类、重置表单并把日期默认设为今天、把焦点移到第一个字段、挂载关闭事件:
function addTransaction() { const dialog = qs('transactionDialog'); if (!dialog) return; dialog.classList.add('show'); // Reset form and set today const form = qs('transactionForm'); if (form) { form.reset(); form.date.valueAsDate = new Date(); form.date.focus(); // 打开后焦点直接落在日期输入框 } const backdrop = dialog.querySelector('[data-dismiss]') || dialog; backdrop.addEventListener('click', onDialogDismissClick); dialog.addEventListener('keydown', onDialogKeydown); }3. Step 2:对话框的两种实现路径(推荐模态弹窗)
作业给出了两条技术路线,并要求二选一:
- Option A:独立页面(Separate Page)——为交易表单新建一个 HTML 模板,在路由系统中新增一条路由,并实现表单页与仪表盘之间的往返导航;
- Option B:模态对话框(Modal Dialog,推荐)——使用 JavaScript 在不离开仪表盘的前提下显示/隐藏对话框,可用 HTML 的
hidden属性 或 CSS 类实现,并做好焦点管理。
方案 B 之所以被推荐,是因为它不触发页面切换,天然适合与 SPA 路由 + 集中式状态配合,交互也更连贯。本仓库解决方案完整实现了 Option B,其结构分为 HTML 标记、CSS 显隐与 JS 控制三部分。
3.1 HTML:对话框标记(挂在 Dashboard 模板内部)
7-bank-project/solution/index.html 中,对话框section使用hidden属性作为默认隐藏手段,同时用 ARIA 属性描述对话框身份:
<section id="transactionDialog" class="dialog" hidden> <div class="dialog-backdrop">.dialog { display: none; /* 默认隐藏 */ position: fixed; inset: 0; /* 覆盖整个视口 */ overflow: auto; background-color: rgba(0,0,0,.45); /* 半透明遮罩 */ justify-content: center; align-items: flex-start; padding: var(--space-sm); z-index: 1000; } .dialog.show { display: flex; } /* 打开时切换为 flex 居中 */.dialog-content还配有弹出动画(animation: dialogIn .25s ease-out both),从透明 + 上移 14px 过渡到正常状态,让开合手感更顺滑。注意:.dialog.show { display: flex }写在.dialog之后,由于两者特异性相同、后者胜出,从而正确覆盖默认的display: none。
3.3 JS:打开与关闭的控制流
关闭对话框的完整逻辑(7-bank-project/solution/app.js)包含三种触发源:点遮罩(data-dismiss元素)、按 Escape 键、点 Cancel 按钮,且关闭后要把焦点还给触发按钮:
function onDialogDismissClick(e) { if (e.target?.hasAttribute?.('data-dismiss')) { cancelTransaction(); } } function onDialogKeydown(e) { if (e.key === 'Escape') { cancelTransaction(); } } 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(); // 焦点返回触发按钮 }关于焦点陷阱(focus trap):作业要求「打开对话框时把焦点困在对话框内」。仓库解决方案采用「打开时聚焦第一个字段 + 关闭时返还焦点」的简化实现。如果你希望严格实现焦点陷阱,需要在
keydown中监听 Tab 键,将焦点限制在.dialog-content内可聚焦元素(input、button)之间循环——这是本作业可以自行强化的进阶点。
4. Step 3:无障碍实现(Accessibility)
作业明确要求对话框满足模态对话框的无障碍标准,分键盘导航与屏幕阅读器两部分:
键盘导航:
- 支持 Escape 键关闭对话框(解决方案已实现,见上方
onDialogKeydown); - 打开时焦点被困在对话框内(解决方案采用聚焦首字段的简化做法);
- 关闭后焦点返回触发按钮(已实现:
cancelTransaction()中opener.focus())。
屏幕阅读器支持:
- 添加恰当的 ARIA 角色与标签——解决方案使用
role="dialog"+aria-modal="true"+aria-labelledby="txDialogTitle"(标题)+aria-describedby="txDialogDesc"(说明文字); - 播报打开/关闭状态——表单错误区
#transactionError使用了role="alert"+aria-live="polite",错误出现时屏幕阅读器会主动朗读; - 提供清晰的字段标签与错误信息——每个
input上方都有<label for="...">,错误信息通过setFormError()写入并focus()到错误节点。
在 7-bank-project/solution/index.html 中,登录/注册表单同样沿用这一套 ARIA 模式(如#loginError、#registerError均为role="alert"),说明这是整个应用的统一无障碍约定,新增对话框应保持一致。
5. Step 4:交易表单的创建与校验
5.1 表单字段设计
作业要求表单收集三个必填字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| Date(日期) | <input type="date"> | 交易发生日期,默认今天 |
| Description(描述/对象) | <input type="text" maxlength="50"> | 交易用途说明 |
| Amount(金额) | <input type="number" step="any"> | 正值表示收入(credit),负值表示支出(debit) |
解决方案对应的 HTML(7-bank-project/solution/index.html):
<label for="date">Date</label> <div class="field"> <input id="date" name="date" type="date" required> </div> <label for="object">Object</label> <div class="field"> <input id="object" name="object" type="text" maxlength="50" required> </div> <label for="amount">Amount (negative for debit)</label> <div class="field"> <input id="amount" name="amount" type="number" value="0" step="any" inputmode="decimal" required> </div>这里有几个值得注意的细节:
type="number"+step="any"允许任意精度(包括小数);inputmode="decimal"在移动端弹出带小数点的数字键盘;- 金额标签直接写明「negative for debit」,在 UI 层就引导用户用负数表示支出;
- 表单位于
<template id="dashboard">内部,由 SPA 路由克隆注入,表单的name属性与FormData直接对应,便于序列化。
5.2 提交前的输入校验
作业要求「提交前校验用户输入,并对非法数据给出清晰错误信息」。解决方案在 7-bank-project/solution/app.js 的confirmTransaction()中做了双重校验:
async function confirmTransaction() { const form = qs('transactionForm'); if (!form) return; // 客户端内联校验 const amountVal = Number(form.amount.value); const objectVal = String(form.object.value || '').trim(); if (!Number.isFinite(amountVal)) { setFormError('transactionError', 'Amount must be a valid number'); return; } if (!objectVal) { setFormError('transactionError', 'Object is required'); return; } clearFormError('transactionError'); const jsonData = JSON.stringify(Object.fromEntries(new FormData(form))); const data = await createTransaction(state.account.user, jsonData); // ...后续状态更新 }配套的错误显示工具函数:
function setFormError(id, message) { updateElement(id, message); const el = qs(id); if (el) el.focus(); // 把焦点移到错误提示,方便读屏用户 } function clearFormError(id) { const el = qs(id); if (el) el.textContent = ''; }校验逻辑值得学习的两点:
- 数量有限但精准:金额用
Number.isFinite()判断是否为有效数字,描述用trim()后判断非空——与后端 7-bank-project/api/server.js 中Amount must be a valid number、Object is required的校验口径一致; - 错误即焦点:
setFormError()不仅写文案,还把焦点移到错误节点,配合role="alert"实现「错误即时播报」。
6. Step 5:API 集成——对接交易录入端点
6.1 端点规格(来自 7-bank-project/api/README.md)
| 方法 | 路由 | 说明 |
|---|---|---|
POST | /api/accounts/:user/transactions | 为指定账户添加交易 |
GET | /api/accounts/:user | 获取指定账户全部数据 |
DELETE | /api/accounts/:user/transactions/:id | 删除指定交易 |
请求体 JSON 格式示例:
{ "date": "2020-07-23T18:25:43.511Z", "object": "Bought a book", "amount": -20 }6.2 服务端行为细节(7-bank-project/api/server.js)
阅读服务端源码,可以确认交易接口的完整行为,这对客户端错误处理至关重要:
- 必填参数:
date、object、amount三者缺一即返回400 { error: 'Missing parameters' }; - 金额校验:
amount无法转换为数字时返回400 { error: 'Amount must be a number' }; - 账户校验:
/api/accounts/:user不存在时返回404 { error: 'User does not exist' }; - 幂等去重:服务端用 MD5(
date + object + amount)生成交易 ID,重复交易返回409 { error: 'Transaction already exists' }; - 成功响应:返回
201与包含id、date、object、amount的完整交易对象,同时服务端会自动更新账户余额(account.balance += transaction.amount)。
⚠️ 注意:API 的 server.js 明确注明数据仅存于内存(in-memory),服务停止即丢失;同时该 API 是为教学预先构建好的,不属于本次作业的修改范围。
6.3 客户端集成:从表单 JSON 到错误处理
解决方案中,createTransaction(user, jsonData)是一个对 API 的封装(在 7-bank-project/solution/app.js 中为本地 mock 实现,模拟了同样的错误语义:Malformed transaction data、Amount must be a valid number、Object is required、Account not found),而confirmTransaction()的集成流程是:
- 用
Object.fromEntries(new FormData(form))收集表单数据; JSON.stringify序列化后交给createTransaction(state.account.user, jsonData);- 若返回
data.error,用setFormError()展示错误并终止流程; - 成功则进入 Step 6 的状态更新环节。
对真实 API 的对接,作业要求「适当处理网络错误」——即try/catch包裹fetch调用,对网络中断、超时给出用户可读的提示(例如「无法连接服务器,请检查 API 是否启动」),这也是本作业中服务端源码没有覆盖、需要你自行完善的客户端逻辑。
7. Step 6:状态管理集成——交易立即可见
这是整个作业的收尾关键:新增交易后,仪表盘必须不刷新页面就立即显示最新交易,并保持状态一致性。
7.1 更新本地状态(7-bank-project/solution/app.js)
// Update local state const newAccount = { ...state.account, balance: (Number(state.account.balance) || 0) + data.amount, transactions: [...(state.account.transactions || []), data] }; updateState('account', newAccount); // Close dialog and update view cancelTransaction(); updateDashboard();这段代码充分体现了前四节课程沉淀的不可变状态更新模式:
- 用展开运算符
...state.account复制旧状态,再覆盖balance与transactions,绝不直接修改state.account; balance的累加方式与服务端一致(原余额 + 交易金额),保证前后端口径统一;- 通过唯一的
updateState()写入新状态,自动触发 localStorage 持久化。
7.2 updateState 与持久化(来自第 4 节课程核心)
回顾 7-bank-project/4-state-management/README.md 建立的updateState()模式,它正是本作业状态集成的基石:
function updateState(property, newData) { state = Object.freeze({ ...state, [property]: newData }); localStorage.setItem(storageKey, JSON.stringify(state.account)); }- 冻结:每次更新都生成新对象并
Object.freeze(),防止意外篡改; - 自动持久化:更新即写
localStorage(key 为savedAccount),这就是「新交易刷新后依然存在」的底层保证; - 集中入口:所有状态变更(登录、注册、登出、刷新数据、添加交易)都走这一个函数,方便调试与审计。
7.3 界面刷新与数据新鲜度
confirmTransaction()在updateState()之后调用updateDashboard(),后者根据state.account重新渲染余额与交易表格。由于状态是响应式的数据源、UI 是状态的投影,交易会立即出现,无需整页刷新。
同时,第 4 节课程引入的「刷新即取新数据」模式(routes['/dashboard']的init: refresh)保证了每次进入仪表盘都会从服务端拉取最新账户数据:
async function updateAccountData() { const account = state.account; if (!account) return logout(); const data = await getAccount(account.user); if (data.error) return logout(); updateState('account', data); } async function refresh() { await updateAccountData(); updateDashboard(); }因此整个作业完成后,应用的交易数据流是:表单提交 → API 创建 → 本地状态更新(含余额与交易数组)→ localStorage 持久化 → updateDashboard 即时重绘。此后刷新页面或重新进入仪表盘,还会从服务端同步最新数据,形成闭环。
8. 测试你的实现
8.1 功能测试
- 验证「Add Transaction」按钮清晰可见且可访问;
- 测试对话框能正常打开与关闭(三种关闭途径:Escape、遮罩/Cancel、提交成功);
- 确认表单对所有必填字段生效(清空描述、输入非数字金额应看到错误提示);
- 检查成功添加的交易立即出现在仪表盘交易列表中,且余额正确变化;
- 确保非法数据(如重复提交同一笔交易)与网络问题(未启动 API)都有明确的错误反馈。
8.2 无障碍测试
- 仅使用键盘完成「打开对话框 → 填写表单 → 提交 → 关闭」的完整流程;
- 使用屏幕阅读器验证对话框标题、描述、字段标签与错误信息的播报;
- 验证焦点管理:打开时进入表单、关闭时返回触发按钮;
- 检查所有表单元素都有恰当的
<label>。
可用的手动验证命令:启动 API 后,模拟一笔来自其他来源的交易以验证数据新鲜度:
curl --request POST \ --header "Content-Type: application/json" \ --data "{ \"date\": \"2020-07-24\", \"object\": \"Bought book\", \"amount\": -20 }" \ http://localhost:5000/api/accounts/test/transactions9. 评估标准(Rubric)
作业给出了三档评估维度,可用于自评:
| 维度 | 优秀(Exemplary) | 合格(Adequate) | 待改进(Needs Improvement) |
|---|---|---|---|
| 功能性 | 功能完美,遵循课程全部最佳实践,用户体验优秀 | 功能正确,但个别最佳实践缺失或有轻微可用性问题 | 功能部分可用或存在显著可用性问题 |
| 代码质量 | 组织良好、遵循既有模式、错误处理完善、与状态管理无缝集成 | 可运行但组织欠佳或与现有代码模式不一致 | 结构性缺陷或未与现有模式集成 |
| 无障碍 | 完整键盘导航、读屏兼容、遵循 WCAG、焦点管理出色 | 基本无障碍已实现,但缺少部分键盘/读屏细节 | 几乎没有无障碍考虑 |
| 用户体验 | 直观、精致的界面,反馈清晰,交互流畅 | 体验良好,反馈或视觉有可改进之处 | 界面混乱、缺少用户反馈 |
对照仓库解决方案:它在「功能」「代码质量」「用户体验」上均达到优秀档,无障碍部分实现了 Escape 关闭、焦点返还、ARIA 与错误播报,但焦点陷阱采用了简化实现,可作为自行加强的方向。
10. 进阶挑战(可选)
作业在基础需求之外,提供了两条进阶路线,全部可选:
增强功能:
- 为交易添加分类(餐饮、交通、娱乐等);
- 实现带实时反馈的输入校验(如输入即校验、防抖提示);
- 为高级用户提供键盘快捷键;
- 增加交易编辑与删除能力。
高级集成:
- 为新添加的交易实现撤销(undo)功能;
- 支持从 CSV 文件批量导入交易;
- 实现交易搜索与过滤;
- 实现数据导出。
这些进阶功能可以结合 7-bank-project/4-state-management/README.md 末尾的「Copilot Agent 挑战」(带 undo/redo 的完整状态历史系统)一起练习——状态历史数组配合不可变更新模式,正是实现撤销/重做的基础。
结语
通过本作业,你在一个真实的银行应用中完成了一条完整的功能开发链路:入口按钮 → 模态对话框 → 无障碍表单 → API 对接 → 集中式状态更新 → 即时 UI 刷新。其核心方法论——不可变状态、单一更新入口、自动持久化、刷新保持新鲜度——正是 Redux、Vuex、Zustand 等主流状态管理库的底层思想。仓库中的完整实现(7-bank-project/solution/app.js、7-bank-project/solution/index.html、7-bank-project/solution/styles.css)与 API 服务(7-bank-project/api/server.js)可作为你动手实现时的对照参考,但强烈建议先独立完成,再对照源码查漏补缺。
【免费下载链接】Web-Dev-For-Beginners24 Lessons, 12 Weeks, Get Started as a Web Developer项目地址: https://gitcode.com/GitHub_Trending/we/Web-Dev-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考