现场编程
【现场编程】天生一队 —— 个人记账本:12 人 3 小时从 0 到可演示
| 项目 | 内容 |
|---|---|
| 所属课程 | 2026-01 软件工程与软件工程实践(H202601)· 福州大学 计算机与大数据学院 https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice |
| 作业要求 | 现场编程 · 截止 2026/11/7 23:00 https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/homework/16781 |
| 作业目标 | 团队现场协作开发一个「个人记账本」Web 应用(增删改查、数据统计、导入导出、用户友好性、简单报表),每位组员独立完成至少一个模块的编写,并完成 Git 协作、联调与现场演示 |
| 团队名称 | 天生一队 |
| 成员名字-学号 | 蔡靖熹 102402116、吴政迅 102402138、庄剑钇 102402152、沈文驰 032401135、梁倩 102401607、王可人 102402104、陈瑀涵 102402107、吴雅晴 102402103、张佩琪 102402102、章玲 102402101、徐张睿 102402139、陈佳慧 052405110 |
一、组员职责分工
12 个人,12 个模块,一人一个 .js 文件,文件级隔离,从根上避免互相改同一份代码。
| 姓名 | 学号 | 负责模块 | 产出文件 |
|---|---|---|---|
| 蔡靖熹 | 102402116 | 统筹分工与 PR 合并;记录列表(倒序展示、按日期分组、单笔删除、列表自动刷新) | js/recordList.js |
| 吴政迅 | 102402138 | 主入口与模块装配、try-catch 错误隔离、tab 联动 | js/app.js、index.html、css/style.css |
| 庄剑钇 | 102402152 | 支出类别饼图(按类别聚合、Canvas 手绘扇形、筛选联动、高清屏适配) | js/statsByCategory.js |
| 沈文驰 | 032401135 | 筛选模块(日期、金额、类别、收支类型),广播 filters:changed 联动列表与图表 |
js/filter.js |
| 梁倩 | 102401607 | JSON / CSV 双向导入导出,按 id 去重入库、防 CSV 公式注入 | js/exportImports.js |
| 王可人 | 102402104 | 数据模型、记录增删改查、金额校验与分币统计 | js/model.js |
| 陈瑀涵 | 102402107 | 新增记账表单(读取、校验、保存、成功提示、事件刷新) | js/addRecord.js |
| 吴雅晴 | 102402103 | localStorage 读写、默认数据初始化、序列化与损坏数据自救 | js/storage.js |
| 张佩琪 | 102402102 | 类别管理(收支预设类别、自定义类别增删、下拉框联动) | js/category.js |
| 章玲 | 102402101 | 通用界面组件:弹窗、提示条、tab 切换与键盘可达性 | js/ui.js |
| 徐张睿 | 102402139 | 收支结余概览卡、内部转账口径排除、按储蓄目标计算每日可花额度 | js/statsOverview.js |
| 陈佳慧 | 052405110 | 近七日收支柱状图、坐标轴与金额刻度优化 | js/statsByDate.js |
整体架构:入口装配(app.js)+ 数据层(storage / model / category)+ 交互层(addRecord / recordList / filter / ui)+ 展示层(statsOverview / statsByCategory / statsByDate)+ 数据交换(exportImports),层与层之间不直接调用,统一通过 document 上的自定义事件通信。
二、程序运行环境
| 项 | 说明 |
|---|---|
| 操作系统 | macOS 与 Windows 11 混合(组员机器不同,故意用来验证跨平台) |
| 浏览器 | Chrome / Edge 100 及以上(依赖 ES Modules、Canvas 2D、localStorage) |
| 技术栈 | HTML5 + CSS3 + 原生 JavaScript(ES Modules)+ Canvas 2D + localStorage,无框架、无后端、无数据库、无第三方 CDN |
| 运行方式 | 需要本地静态服务器(ES Modules 受 CORS 限制,不能直接双击 index.html) |
| 测试环境 | Node.js 18+,npm test 顺序跑 tests/storage.mjs 与 tests/smoke.mjs |
| 数据存储 | 浏览器 localStorage,键名 team2_personal_bookkeeping_v1,结构 { version, records, categories } |
启动命令:
# 方式一:Python 自带静态服务器
cd softwareengineering-teamproject02-2026
python3 -m http.server 8000
# 浏览器打开 http://127.0.0.1:8000
# 方式二:Node
npx serve .
跑测试:
npm test
# 输出 PASS: storage / model / category / import / csv 全绿
数据规模:题目要求支持 100 条记录。记录列表按日期分组一次渲染,统计图表在数据变化时整体重绘一次,渲染成本与记录数线性相关,百条量级内无可感知延迟。
三、软件运行截图和视频
以下截图取自演示时的真实数据:3 笔记录(收入 1 笔 ¥1,000.00、支出 2 笔合计 ¥115.50),列表计数、概览卡、饼图、柱状图四处数字完全自洽。
图 1|新增记账

五个字段各有约束:金额必须是正数、类别按当前收支类型动态重建下拉、备注选填。保存成功后右下角弹出「支出 ¥35.50 保存成功」,并广播 records:changed 让列表、概览、图表一起刷新。
图 2|记录明细与筛选区

左侧筛选区有五个条件:开始/结束日期、收支类型、类别、最低/最高金额。它不只过滤列表——同一份筛选结果会分发给概览卡和两张图表,所以「筛完列表和统计对不上」这类问题在结构上就不存在。每条记录右侧带单笔删除,删除后同样走事件刷新。
图 3|筛选联动:类别选「餐饮」

把类别切到「餐饮」,列表立刻从 3 条收敛成 1 条,底部文案同步变成「共 1 笔符合条件的记录」。此时切到统计页,饼图只剩一个 100% 的扇形——筛选条件是被图表继承的,不是各自为政。
图 4|统计分析(上):收支概览与预算规划

总收入 ¥1,000.00、总支出 ¥115.50、结余 ¥884.50,与明细页的 3 条记录逐笔对得上。下方「本月预算规划」按自然日推算剩余可用额度(标注了「不受筛选影响」,与上方概览的口径区分开)。
图 5|统计分析(下):类别饼图与收支趋势

左边是本博客第三章要讲的重点模块:支出类别饼图,购物 69.3%(¥80.00)、餐饮 30.7%(¥35.50),绝对值与百分比同时标在图例里;右边是近七日收支柱状图,收入绿色、支出红色,无记录的日期留空而不是补零,避免视觉上出现假的「当天消费为 0」。
图 6|类别管理

预设类别标记「预设」,且带「N 条记录,不能删除」的说明;用户自定义的类别只要还有记录引用它,同样锁住删除按钮——这是防止删类别导致历史记录变成孤儿数据。新增「娱乐」后右下角即时提示,新增页的类别下拉也已同步出现。
图 7|数据导出与导入

导出分 JSON(保留完整结构,含类别配置)与 CSV(方便 Excel 查看)两个入口;导入区接受 JSON/CSV,按 id 去重合并,重复导入同一份文件不会产生重复记录。截图里提示「已导出 3 笔 JSON 记录」。
演示视频
若上方播放器打不开,可用备用地址:GitHub Pages 版
视频走完整闭环:加一笔 → 看列表 → 改筛选 → 看饼图跟着变 → 导出 CSV → 清空后重新导入。截图与视频在演示后由沈文驰统一录制整理。
四、关键代码片段与注释说明
本项目 12 个模块由 12 名组员分头编写,最终 12 个 js 文件合计约 1810 行。下面按「装配 → 存储 → 校验 → 筛选 → 渲染 → 统计 → 备份」这条数据流,从 8 位组员各自负责的模块里各摘一段核心代码,合计约 130 行。所有片段的核心逻辑与仓库 main 分支一致,可对照查看(为控制篇幅,个别重复的初始化调用以「……」省略,代码本身未作改写)。
另需说明:分工方案原定「Chart.js(CDN 引入画图)」,实际落地改成了 Canvas 2D 手绘,原因写在第五章亮点一。所以下面看到的是
ctx.arc/fillRect,页面上也不存在任何第三方脚本请求。
4.1 入口装配:一个模块崩了,不拖垮整站(js/app.js · 吴政迅)
function safeInit(name, fn) {
try {
fn();
console.log(`[app] ✅ ${name} 初始化成功`);
} catch (error) {
// 这个模块崩了,打印错误,但不影响其他模块
console.error(`[app] ❌ ${name} 初始化失败:`, error);
showToast(`${name} 模块加载失败:${error.message}`, 'error');
}
}
function initializeApp() {
safeInit('storage', initStorage); // 最先:后面所有模块都要读写数据
registerGlobalEvents(); // 再挂全局事件,避免早期变更丢失
safeInit('tabs', () => initTabs((tab) => {
document.dispatchEvent(new CustomEvent('app:tab-changed', { detail: { tab } }));
}));
safeInit('recordList', initRecordList); // 之后才是各业务模块
safeInit('statsByCategory', initStatsByCategory);
// ……其余模块同理
}
三点值得说:① 顺序有依赖,存储层必须先于一切;② 每个模块都用 safeInit 包一层 try-catch,某个模块抛异常只会弹一条错误提示,其余功能照常可用——这也是我们敢在最后一小时继续合 PR 的底气;③ 模块之间不互相调用函数,只通过 document 上的自定义事件通信。
4.2 本地存储:脏数据先备份,再自愈(js/storage.js · 吴雅晴)
function isValidDate(date) {
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return false;
const parsed = new Date(`${date}T00:00:00Z`);
// 先正则再回读:2026-02-30 格式合法但日期不存在,Date 会把它自动滚到 3-02,
// 用 toISOString() 反比对,就能把这类「看着合法」的假日期挡在外面
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === date;
}
function repairData(raw) {
if (!isPlainObject(raw)) return null;
const seen = new Set();
const records = [];
(Array.isArray(raw.records) ? raw.records : []).forEach((record) => {
if (!isValidRecord(record) || seen.has(record.id)) return; // 字段非法或 id 重复,直接丢弃
seen.add(record.id);
records.push({
id: record.id,
type: record.type,
amount: Math.round(record.amount * 100) / 100, // 金额统一收敛到两位小数
category: record.category.trim(),
date: record.date,
note: typeof record.note === 'string' ? record.note.slice(0, 200) : '', // 备注截断,防超长污染
createdAt: typeof record.createdAt === 'string' ? record.createdAt : '',
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : (typeof record.createdAt === 'string' ? record.createdAt : '')
});
});
return { version: DATA_VERSION, records, categories: normalizeCategories(raw.categories) };
}
localStorage 最怕两件事:用户手改和旧版本数据。所以读的时候不是直接 JSON.parse 就用,而是先 repairData() 逐条体检,修不好的原始字符串会另存到 team2_personal_bookkeeping_v1:corrupt:<时间戳> 再恢复默认值——数据宁可少,也不能悄悄丢。修复成功的数据会立即写回,避免每次打开都重算一遍。
4.3 数据模型:金额一律归一到「分」(js/model.js · 王可人)
export function validateAmount(amount) {
if (amount === null || amount === undefined || String(amount).trim() === '') {
throw new Error('请输入金额');
}
const value = Number(amount);
if (!Number.isFinite(value) || value <= 0) throw new Error('金额必须大于 0');
const cents = Math.round(value * 100);
// 用「换算成整数分再回比」的方式判断小数位:
// 12.345 这种三位小数,round 后与原始值差 0.5 分,超过 1e-6 即判非法
if (!Number.isSafeInteger(cents) || cents < 1 || Math.abs(value * 100 - cents) > 1e-6) {
throw new Error('金额最多保留两位小数,且不能超过安全范围');
}
return cents / 100;
}
这段是全项目金额口径的源头:入口处把「元」收敛成两位小数,之后所有累加都用 Math.round(amount * 100) 转成分做整数运算,只在渲染时除回 100。饼图、概览卡、柱状图三处统计全部遵循这个口径,所以三张图上的数字永远对得上。
4.4 筛选联动:日期区间互相约束(js/filter.js · 沈文驰)
function syncDateBounds() {
const start = element('filter-start');
const end = element('filter-end');
if (!start || !end) return;
if (start.value) end.min = start.value; // 选了开始日期,结束日期的可选下限随之前移
else end.removeAttribute('min');
if (end.value) start.max = end.value;
else start.removeAttribute('max');
}
function alignDateRange(changedId) {
const start = element('filter-start');
const end = element('filter-end');
if (!start?.value || !end?.value || start.value <= end.value) return;
// 手输日期能绕过选择器的 min/max 约束,这里兜底把另一头对齐,并明确告知用户
if (changedId === 'filter-start') end.value = start.value;
else start.value = end.value;
showToast('开始日期与结束日期已自动对齐', 'info');
}
筛选区是全站唯一的数据入口:列表、概览卡、饼图、柱状图都调 getFilteredRecords() 拿同一份结果,所以「筛完列表和统计对不上」这类问题在结构上不可能出现。日期区间做了两层防护——选择器层面用 min/max 互锁,脚本层面再兜一次底,手输 2026-11-10 到「开始」、却把「结束」留成 2026-11-01 时会自动对齐并提示。
4.5 列表渲染:全程 textContent,天然免疫 XSS(js/recordList.js · 蔡靖熹)
function make(tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined) node.textContent = content; // 关键:textContent,而不是 innerHTML
return node;
}
const main = make('div', 'record-main');
main.appendChild(make('span', 'record-category', record.category));
main.appendChild(make('span', 'record-note', record.note || '无备注'));
备注是用户自由输入,如果图省事拼 innerHTML,一条备注写成 <img src=x onerror=alert(1)> 就会在别人的浏览器里执行。整站所有列表、标签、金额文本都走这个 make(),标签只会被当成文字显示。同理,列表刷新用的是 root.replaceChildren() 而不是 innerHTML = ''。
4.6 分类饼图:整数聚合 + 手绘扇形(js/statsByCategory.js · 庄剑钇)
function categoryTotals() {
const map = new Map();
// 只统计支出(type === 'expense'),收入不参与饼图
// 数据来自筛选模块,所以用户改筛选条件时,饼图会自动跟着变
getFilteredRecords().filter((r) => r.type === 'expense').forEach((r) => {
// 关键:金额先乘 100 取整再累加,避免 0.1 + 0.2 = 0.30000000000000004 这类浮点误差
map.set(r.category, (map.get(r.category) || 0) + Math.round(r.amount * 100));
});
// 按金额降序,保证扇形与图例顺序一致,占比最大的排第一
return [...map.entries()].sort((a, b) => b[1] - a[1]).map(([name, cents]) => ({ name, cents }));
}
export function initStatsByCategory() {
document.addEventListener('records:changed', renderStatsByCategory); // 增删改记录后
document.addEventListener('filters:changed', renderStatsByCategory); // 筛选条件变化后
document.addEventListener('app:tab-changed', (event) => {
// 切到统计 tab 时补一次重绘:此前 panel 是 hidden,画布尺寸为 0 被跳过
if (event.detail.tab === 'stats') requestAnimationFrame(renderStatsByCategory);
});
window.addEventListener('resize', () => {
if (!document.getElementById('panel-stats')?.hidden) renderStatsByCategory();
});
renderStatsByCategory();
}
绘制部分还有两个不显眼但必要的处理:configureCanvas() 里先判断 clientWidth/clientHeight,为 0 就返回(tab 隐藏时画布是 0×0,硬画会得到一张空白图);拿到尺寸后按 devicePixelRatio 放大画布再用 ctx.setTransform(dpr,0,0,dpr,0,0) 缩回坐标系,高分屏上饼图边缘才不会发虚。扇形半径取 Math.min(width * 0.38, height * 0.42, 112) 三个上限的最小值,窄屏不溢出、宽屏不失控。
4.7 趋势柱状图:1/2/5 的漂亮刻度(js/statsByDate.js · 陈佳慧)
function getNiceMax(value) {
const target = value * 1.1; // 顶部留 10% 余量,柱子不顶到边框
const magnitude = 10 ** Math.floor(Math.log10(target)); // 取量级,1234 → 1000
const normalized = target / magnitude; // 归一化到 1~10 区间
let niceValue;
if (normalized <= 1) niceValue = 1;
else if (normalized <= 2) niceValue = 2;
else if (normalized <= 5) niceValue = 5;
else niceValue = 10;
return niceValue * magnitude; // 1234 → 2000,Y 轴落在整千上
}
手绘图最花时间的是坐标轴:如果直接拿最大值当上限,Y 轴会出现 ¥1234、¥617 这种刻度,读起来很难受。这里把上限吸附到 1/2/5 × 10ⁿ,无论数据是 87 还是 12800,刻度都落在整数档位上。另外柱状图对「没有记录的日期」是留空而不是补 0,避免视觉上出现假的「当天消费为 0」。
4.8 导出导入:防公式注入,导入按 id 去重(js/exportImport.js · 梁倩)
function csvValue(value) {
const text = String(value ?? '');
// 防止用户备注变成 Excel 公式:以 = + @ - 开头时加前导单引号
// 但 -12.5 这种纯负数金额要排除,否则负数会被误伤成文本
const safe = /^[\s]*[=+@\-]/.test(text) && !/^-?\d+(?:\.\d+)?$/.test(text)
? `'${text}` : text;
return `"${safe.replaceAll('"', '""')}"`; // 双引号转义成两个,符合 RFC 4180
}
const knownIds = new Set(current.records.map((r) => r.id));
const idsInFile = new Set();
rows.forEach((item, index) => {
// ……逐字段校验 type / amount / date / category / note
if (idsInFile.has(id)) throw new Error('导入文件中存在重复 ID'); // 文件内部重复,直接报错
idsInFile.add(id);
if (knownIds.has(id)) { skipped++; return; } // 与本地重复,跳过不覆盖
incoming.push({ id, type, amount, category, date, note: note.trim(), createdAt, updatedAt });
});
三个容易忽略的点:① CSV 导出前给文本加前导单引号,是为了挡住「备注里写 =HYPERLINK(...),导出后用 Excel 打开自动执行」这类公式注入,同时用负号判断排除纯数字,否则 -12.5 会被当成文本;② 导出时给整份文件加 \uFEFF(BOM),Excel 打开中文才不会乱码;③ 导入分「文件内重复」和「与本地重复」两种情况,前者报错、后者跳过并统计,所以同一份 CSV 反复导入只会提示「新增 0 笔,跳过重复 N 笔」,不会把数据翻倍。
五、软件的 1-3 项亮点
亮点一:全站零第三方依赖,两张图表都是 Canvas 手绘,断网也能演示。
现场编程最大的不确定性是网络。最终版本没有引 Chart.js 之类的 CDN,饼图用 ctx.arc 拼扇形、柱状图用 fillRect 画柱子,整套东西只有一个 HTML + 一个 CSS + 12 个 js 文件,index.html 里唯一的 <script> 就是 ./js/app.js,断网、换电脑、拷 U 盘都能直接跑。
这里其实改过一次方案:分工方案(现场编程分工方案-天生一队.docx)的技术栈原本写的是「原生 JavaScript + localStorage + Chart.js(CDN 引入画图)」,亮点规划里写的也是「纯前端 Chart.js 实现」。联调时我们把它推翻了,理由是三条:① 现场网络不可控,CDN 一旦抽风,饼图和柱状图会同时白屏,而这两张图正好是「数据统计」这条要求的全部证据;② 为了一个饼图引入 200 KB 的库,和「零依赖、打开就能演示」的目标相冲突,还要额外处理 4.x 与 2.x 语法差异;③ 概览卡、图例、两套图的配色要统一,自己画反而更好控。代价是角度、坐标轴刻度、百分比排版都得手算,收益是整站零外部请求、演示零风险。
亮点二:JSON / CSV 双向导入导出,带 id 去重与防公式注入。
localStorage 一清空数据就没了,所以我们做了两条备份通道:导出 JSON 保留完整结构(含类别配置),导出 CSV 方便用 Excel 看;导入时按 id 去重后整批入库,重复文件反复导入不会产生重复记录,同一文件内出现重复 id 则直接报错。CSV 还处理了一个容易被忽略的细节——单元格若以 =、+、@、- 开头就加前导单引号,防止用户备注被 Excel 当成公式执行;但正则同时排除了纯数字,否则负数金额会被误伤成文本。导出时加 UTF-8 BOM、导入时剥掉 BOM,Excel 与记事本打开都不乱码。
亮点三:12 个模块靠事件解耦 + 入口错误隔离,一个模块崩了不拖垮整页。
所有模块各自 export 自己的 init,由 app.js 统一装配,模块之间只通过 records:changed、filters:changed、app:tab-changed 三个自定义事件通信,谁都不 import 谁的内部函数。装配时每个模块都包在 try-catch 里,某个模块初始化抛异常只会在控制台报一条,其余功能照常可用——这也是我们敢在最后一小时继续合 PR 的底气。此外金额全程按「分」整数运算(4.1 节),从根上避免 0.01 元级别的累计偏差。
六、花时间较长、收获较大的 3 件事
事件一:双击 index.html 一片白屏 —— 查了约 40 分钟
项目一开始是每个模块单独写、单独测,各自双击 HTML 都能看效果。等 app.js 把模块串起来,一打开就是白屏,控制台一行红字:Access to script at 'file:///...' from origin 'null' has been blocked by CORS policy。
原因:项目用了 ES Modules(import / export),浏览器对模块脚本强制走 HTTP 同源策略,file:// 协议下 origin 是 null,直接被拦。
解决:统一改成 python3 -m http.server 8000 启动,并在 README 里把启动命令写成第一条;演示前先起服务器再打开浏览器。
收获:import 不是「更现代的 script」,它改变了加载方式,也改变了运行前提。这个坑如果留到演示现场才发现,就是直接丢分。
事件二:仓库拉下来也是白屏 —— 这次是模块接口断了
17:00 现场演示用的是组长本机上的那份代码,功能全通。晚上把主分支 clone 下来准备做回归,打开却是一片白屏:tab 点不动、金额全是 ¥0.00、类别下拉里只剩「请选择类别」。控制台第一行红字:
Uncaught SyntaxError: The requested module './storage.js' does not provide an export named 'load'
原因:js/model.js 是另一个人按早期架构写的——自己一套 localStorage 键 BillApp:records,调用 storage.load();而其余 11 个模块统一用 team2_personal_bookkeeping_v1 和 loadData()/saveData()。ES Modules 的依赖图只要断一处,入口脚本一行都不会执行,整个应用停在静态 HTML 上,看起来像"页面没加载完",实际是脚本全死。同一份文件还把删除函数导出成 removeRecord,而记录列表要的是 deleteRecord,第二处断链。
处理:换成与 storage.js 接口一致的版本,补齐记录列表依赖的导出;然后用全新 clone 从零跑一遍完整链路(新增、筛选联动、饼图、柱状图、导出 JSON/CSV、导入去重、刷新持久化、npm test),全部通过才提交。
收获:演示能跑 ≠ 仓库能跑。只要有一个人本地的改动没提交,别人 clone 下来就是坏的;而"我这儿跑得挺好"恰恰是最容易骗过所有人的状态——大家都以为是环境问题,没人怀疑代码。另外 ES Modules 只报断链的那一处,剩下 11 个模块本身是好的却一起不执行,排查时要先盯第一条红字,别被"所有功能都没反应"带偏。
事件三:PR 里躺着一份接口对不上的代码 —— 靠 Review 拦下来的
分工确定得早,饼图一开始是按「全局命名空间 window.BillApp + Chart.js」这套接口写的,代码本身能跑、单页测试也过。但团队实际落地的是 ES Modules + 事件总线:数据从 filter.js 的 getFilteredRecords() 取,重绘靠监听事件。
如果直接把那份代码合进主分支,import 找不到、BillApp 无人挂载,统计页会变成空白——而且因为它「自己单独跑得挺好」,很容易被误判成"没问题"。
处理:在 PR 的 Files changed 里逐行比对接口,确认不兼容后重写为 import { getFilteredRecords } from './filter.js',事件名对齐 records:changed / filters:changed / app:tab-changed,并把「分」累加与 dpr 适配一并补上,重新提交后 PR #2 才被合并。
收获:模块「能跑」不等于「能合」。接口约定要在动手前定死并写进 README;PR 的 diff 是最后一道闸,合并前必须有人真的逐行看。
有意思的是,同一类病根(接口没对齐)当天犯了两次:下午这次在 PR 里被 diff 拦住了,晚上它换个文件直接合进 main,就变成了事件二里的白屏。闸门只在有人值守的时候才叫闸门。
附带一个小坑:饼图在「记录明细」tab 里始终画不出来。原因是所在 panel 是
hidden,clientHeight为 0,画布尺寸 0×0。改成「检测到尺寸为 0 就跳过,切到统计 tab 时用requestAnimationFrame补一次重绘」后解决。
七、每个组员的团队编程体验
蔡靖熹(记录列表)
我负责个人记账本的记录列表模块,实现收支记录倒序展示、按日期分组、单笔删除及列表自动刷新,并完成模块接口对接与功能测试。作为组长还要收齐 12 个人的 PR、盯合并顺序。感受最深的是:让代码跑起来只是第一步,让 12 份代码合成一个能跑的整体,才是真正的工程活。
吴政迅(主入口 / 装配)
我负责协调沟通和 app.js 主入口,把同学们的模块装配起来,用 try-catch 做错误隔离,一个模块崩了不影响全局。第一次团队编程,体会到模块化分工和协作的重要性——每个人只管自己那一个文件,冲突几乎为零,但事件名、容器 id 这些「公共契约」必须有人统一。
庄剑钇(分类支出饼图)
我负责 statsByCategory.js:按类别聚合支出、用 Canvas 手绘扇形,并跟随筛选条件和记录变化自动重绘。中途我一度按另一套接口(Chart.js + 全局命名空间)实现,Review 时才发现和团队的 ES Modules + 事件总线对不上,重写后才合进主分支。这件事让我记住:动手前先读别人的代码怎么约定接口,比自己写得快更重要。
沈文驰(筛选)
我负责 filter.js,实现日期、金额、类别和收支类型筛选,筛选后广播 filters:changed,联动列表和统计图。体会是接口要先定死、事件要统一,联调才顺。
梁倩(导入导出)
我负责导出导入:JSON / CSV 下载与导入,按 id 去重后整批入库,还做了防公式注入。测试时发现,导入的异常边界最费心思——空文件、少列、类型不对、重复导入,每一种都得有明确提示,不能白屏。
王可人(数据模型 / 分币统计)
我负责记录的增删查、校验与分币统计,并新增类别管理:餐饮、交通、购物、工资预设,支持自定义增删。体会到模块解耦与边界校验的重要性——边界没守住,脏数据会一路流到统计图表里。
陈瑀涵(新增记账)
我负责个人记账本新增记录模块的开发,实现表单数据读取、金额校验、记录保存、成功提示和事件刷新,并完成模块联调与功能测试。加深了对模块化设计、事件通信和前端数据校验的理解。
吴雅晴(localStorage)
我负责实现 localStorage 读写、默认数据初始化、JSON 序列化与损坏数据自救。动手前先把数据结构定成 { version, records, categories },再按接口约定对接 model 和 category。最大收获是:接口约定比代码量更重要,存储层多做一点校验,演示时就不会因为一条脏数据白屏。
张佩琪(类别管理)
我负责类别管理模块,支持收入 / 支出预设类别、自定义类别新增和删除,并完成相关界面样式及记账、筛选下拉框的联动。开发中体会到模块间接口和数据同步很重要,也加深了对输入校验、本地存储和事件联动的理解。
章玲(通用 UI 组件)
我负责记账本的通用界面组件:弹窗、提示条、tab 切换。联调时测试了方向键切换,发现键盘焦点落在导航按钮上时方向键才会生效,于是补了焦点管理。细节不做,用户就会觉得「点不动」——这大概就是题目里「用户友好性」的意思。
徐张睿(收支概览)
我负责统计概览模块:做了收支结余三张卡,并把信用卡还款这类内部转账排除出支出,再按储蓄目标算每天能花多少。最大的体会是:统计不是把数字加起来,而是先想清楚哪些钱不该算。
陈佳慧(日期收支柱状图)
我负责日期收支统计图表模块,实现近七日收支柱状图、筛选联动和自动刷新,优化金额刻度显示。体会到数据处理与模块协作的重要性——数据口径一旦和别人不一致,图上看着正常,总数却是错的。
八、参考
作业提供的范例博客: http://www.cnblogs.com/vertextao/p/7469789.html ;http://www.cnblogs.com/vertextao/p/7469789.html;
九、GitHub 仓库地址和 commit 记录
仓库地址:https://github.com/TCHHPK/FZU-SoftwareEngineeringTeamProject02-Team2
项目结构:
.
├── index.html
├── css/style.css
├── js/ # 12 个模块,一人一个文件
│ ├── app.js addRecord.js recordList.js filter.js
│ ├── model.js storage.js category.js ui.js
│ ├── statsOverview.js statsByCategory.js statsByDate.js exportImports.js
├── tests/ # storage.mjs + smoke.mjs
└── package.json
协作数据:截至提交,主分支共 37 次提交、12 位贡献者,每人都有以自己账号提交的记录。
| 贡献者 | 提交数 | 贡献者 | 提交数 |
|---|---|---|---|
| TCHHPK(蔡靖熹) | 5 | Luminance-l(章玲) | 2 |
| Xun1342(吴政迅) | 5 | Starry-Nova(张佩琪) | 1 |
| always123-11(庄剑钇) | 6 | VioletQAQ666(徐张睿) | 1 |
| 102402103wuyaqing(吴雅晴) | 3 | asakinza(王可人) | 1 |
| zzzoe116(陈佳慧) | 3 | cccch412(陈瑀涵) | 1 |
| KongCheng06(梁倩) | 2 | otonash1(沈文驰) | 1 |

对应的 Pull Request:https://github.com/TCHHPK/FZU-SoftwareEngineeringTeamProject02-Team2/graphs/contributors?from=2026%2F8%2F1
提交规范:统一使用 feat(模块): 说明 / fix(模块): 说明 / docs: 说明 前缀,例如 feat(stats): 新增分类支出饼图模块 statsByCategory.js、feat: 日期收支图表支持连续七日展示且增加收入支出图例。这样从 commit 列表就能看出谁在什么时间动了哪一块,便于复盘与答辩。
写在最后
3 小时、12 个人、12 个文件、37 次提交,最后能在一个浏览器里点完整条链路。这次现场编程最大的体会不是某个技术点,而是三件很朴素的事:接口先定死(事件名、容器 id、数据结构)、目录先统一(js/ 下不许平铺)、合并前必须有人看 diff。技术债现场还不完,但流程债当场就能还。

浙公网安备 33010602011771号