现场编程

【现场编程】天生一队 —— 个人记账本: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|新增记账

01-新增记账

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

图 2|记录明细与筛选区

02-记录明细

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

图 3|筛选联动:类别选「餐饮」

03-筛选结果

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

图 4|统计分析(上):收支概览与预算规划

04-统计分析-概览

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

图 5|统计分析(下):类别饼图与收支趋势

05-统计分析-图表

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

图 6|类别管理
06-数据管理-类别

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

图 7|数据导出与导入

07-数据管理-导出

导出分 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

image

对应的 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。技术债现场还不完,但流程债当场就能还。

posted @ 2026-11-07 20:50  庄剑钇  阅读(27)  评论(0)    收藏  举报