现场编程

现场编程:个人记账本系统 项目总结

项目 内容
作业所属课程 202601软件工程与软件工程实践(计算机与大数据学院)
作业要求 现场编程
作业的目标 在有限时间内,通过团队协作完成一个具备基础记账、多维度统计及图表展示的个人记账本系统,并熟练掌握 Git 分支协作与接口规范。
团队名称 outfit合伙人
团队成员 [S1阿合加依娜尔-102402108]、[S2谢慧彬-102402114]、[S3王凯凤-052403108]、[S4吴佳仪-102402113]、[S5刘翠华-102402105]、[S6李青容-102402111]、[S7姜思源-102402110]、[S8陈薇薇-102402109]、[S9松格拉-102402112]、[S10王恩嘉-072401214]、[S11张润-102402115]、[S12赖彦然-102402106]、[S13林诗怡-052402111]

一、 组员职责分工

编号 岗位/模块 交付物 具体职责
S1 页面与集成 index.html、js/app.js、vendor/ 首页布局、页面框架、模块挂载编排、月份状态、Toast/确认弹窗
S2 记账表单 form.js 表单注入/绑定、必填与合法性校验、新增记录
S3 账单列表 records.js 监听更新事件、账单渲染、删除记录
S4 财务计算 finance.js 收入、支出、余额、多维度汇总计算
S5 筛选查询 filter.js 日期、类别、收支类型、金额范围筛选
S6 统计图表 charts.js 收支趋势、类别占比图表渲染
S7 数据存储 storage.js 统一数据读写、增删改查接口、事件派发
S8 数据导入导出 export.js 严格格式化 JSON 导出;安全导入及逐条校验
S9 测试负责人 tests/ 编写测试用例、测试数据保护机制
S10 UI 体验优化 style.css 统一配色、响应式布局与交互反馈
S11 GitHub 与文档 README.md 文档编写、接口规范整理、协作记录整理
S12 演示与发布 demo-data.js 演示数据生成、版本发布管理
S13 项目总负责人 main 分支 架构设计、接口审查、代码合并与集成

二、 程序运行环境

  • 操作系统:跨平台(Windows / macOS / Linux)。
  • 浏览器要求:推荐使用最新版 Chrome、Edge 或 Firefox 浏览器(完美支持 ES6 语法与 HTML5 LocalStorage)。
  • 项目依赖:纯前端原生实现,无需安装 Node.js 等构建工具或后端数据库。
  • 启动方式:
    1. 克隆或下载仓库后,直接双击根目录下的 index.html 即可运行体验。
    2. 若需运行自动化测试,请在根目录执行 python -m http.server 8000,并访问 http://localhost:8000/tests/test-runner.html。

三、 软件运行截图与视频

1. 核心业务界面

功能展示说明:

顶部仪表盘:

实时展示当月及累计的收支与结余,数据随记录变动动态重算。

左侧记账区:

支持收/支切换,提供快捷金额标签与分类图标,交互流畅。

右侧数据可视化 :

利用第三方图表库实现了“收支对比柱状图”、“分类占比环形图”与“每日收支趋势折线图”。

底部高级查询与日历 :

支持收支类型、分类、日期区间、金额区间等多条件组合筛选;左侧更设计了直观的日历视图,方便用户按天核对账单。

11fa1f724411df746a209aa520c66d8b

演示视频:

“记一笔”-收入:

aa66c9b2559db6fc281e5d5e24a015cb

通过日历查看每日收入/支出:

98925869e60949710a849499a3836f87

导出数据

8F3D069A985ABC582FF8304D62FA6D12

编辑账单

1

按月查看收支情况

2


四、 关键代码与设计说明

1. 财务计算防精度丢失 (S4 - finance.js)

设计说明:金额计算统一转换为“整数分”,避免 JS 浮点数计算误差(如 0.1+0.2=0.30000000004)。

// 安全地把金额转为"分":只接受数字或非空数字字符串,其余一律视为 0
function toCents(val) {
  let num;
  if (typeof val === 'number') {
    num = val;
  } else if (typeof val === 'string' && val.trim() !== '') {
    num = Number(val);
  } else {
    return 0;
  }
  if (!Number.isFinite(num) || num <= 0) return 0;
  return Math.round((num + Number.EPSILON) * 100);   // EPSILON 修正二进制浮点误差
}

// 对外接口:最后才把"分"换回"元"
function calculateBalance(records) {
  const totals = totalsCents(records);
  return fromCents(totals.income - totals.expense);
}

2. 数据存储与事件派发 (S7 - storage.js)

设计说明:底层存储修改后派发全局事件,实现模块解耦,各 UI 模块无需相互调用。

var silent = false;   // 内部批量写入时临时关闭事件,避免重复触发

// 统一派发事件 ledger:updated,detail.action 标记本次操作类型
function emit(action, payload) {
  if (silent) return;
  var doc = global.document;
  if (!doc || typeof doc.dispatchEvent !== 'function') return;
  var detail = { action: action, payload: payload || null };
  try {
    var evt = new global.CustomEvent('ledger:updated', { detail: detail });
    doc.dispatchEvent(evt);
  } catch (e) {
    console.warn('[storage] ledger:updated 事件派发失败', e);
  }
}

// 整体替换数据:先逐条校验清洗,再写入;成功后派发事件
function saveRecords(records) {
  if (!Array.isArray(records)) {
    console.error('[storage] saveRecords 需要一个数组');
    return false;
  }
  var clean = [];
  for (var i = 0; i < records.length; i++) {
    var rec = buildRecord(records[i], false);   // 逐条校验,脏数据跳过
    if (rec) clean.push(rec);
  }
  try {
    writeRaw(clean);
  } catch (e) {
    lastError = e;
    console.error('[storage] 整体写入失败:', e);
    return false;
  }
  emit('replace', { count: clean.length });
  return true;
}

五、 软件亮点设计

亮点一:金额全程以”分”为单位的整数运算,彻底规避浮点误差

设计说明:

JavaScript 的浮点数运算存在问题:0.1 + 0.2 = 0.30000000000000004,如果直接对金额做加法,日积月累就会出现误差。

本项目在财务统计模块中做了专门设计:

  • 所有金额在进入统计前,先用 toCents() 把”元”换算成”分”(整数),并配合 Number.EPSILON 修正二进制表示误差,再 Math.round 取整;
  • 统计过程中全程只对整数”分”做累加,最后才用 fromCents() 一次性换算回”元”。

这样既避免了浮点误差累积,也让比较、求和全部落在整数域上,结果精确可靠。同一思路也体现在数据存储层的 buildRecord(金额统一 Math.round(amount * 100) / 100 保留两位小数)。

配套的真实日期校验也处理得很细致:isValidDate() 没有依赖 new Date('2026-02-31') 这种会自动”进位”的宽松解析,而是手写闰年规则 + 每月天数表来判定,同时刻意绕开了 Date 对象把 0-99 年份当作 1900 年代处理的问题。

关键代码(js/finance.js):

// 安全地把金额转为”分”。只接受数字或非空数字字符串,其余一律视为 0
function toCents(val) {
  let num;
  if (typeof val === 'number') {
    num = val;
  } else if (typeof val === 'string' && val.trim() !== '') {
    num = Number(val);
  } else {
    return 0;
  }
  if (!Number.isFinite(num) || num <= 0) return 0;
  return Math.round((num + Number.EPSILON) * 100);   // EPSILON 修正二进制误差
}

function fromCents(cents) {
  return Math.round(cents) / 100;
}

// 一次性统计总收入/总支出(单位:分),非法日期记录不参与统计
function totalsCents(records) {
  let income = 0, expense = 0;
  if (!Array.isArray(records)) return { income, expense };
  for (let i = 0; i < records.length; i += 1) {
    const record = records[i];
    if (!record || typeof record !== 'object') continue;
    if (!isValidDate(record.date)) continue;
    const cents = toCents(record.amount);
    if (record.type === 'income') income += cents;
    else if (record.type === 'expense') expense += cents;
  }
  return { income, expense };
}

// 对外接口:最后才把”分”换回”元”
function calculateBalance(records) {
  const totals = totalsCents(records);
  return fromCents(totals.income - totals.expense);
}
// 手写闰年规则的真实日期校验
function isValidDate(date) {
  if (typeof date !== 'string' || !DATE_RE.test(date)) return false;

  const [year, month, day] = date.split('-').map(Number);

  // 使用闰年规则和月份天数判断,避免 Date 对 0-99 年的特殊处理
  const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
  const daysInMonth = [
    31, isLeapYear ? 29 : 28, 31, 30, 31, 30,
    31, 31, 30, 31, 30, 31
  ];
  return day >= 1 && day <= daysInMonth[month - 1];
}

亮点二:事件驱动 + 单一数据源的分层解耦架构

设计说明:

这是一个 13 名成员分工协作的团队项目,多人并行开发最大的风险就是”互相改乱、重复刷新”。本项目用一套清晰的架构解决了这个问题:

  • 唯一数据入口:所有数据的增删改查必须经过 LedgerStorage(S7 模块),规范明令禁止其他模块直接操作 localStorage。数据格式、校验、时间戳维护都收口到这一处。
  • 统一事件通知:任何数据变化(新增/修改/删除/导入/清空/初始化)都由存储层统一派发一次 ledger:updated 事件,各界面模块监听它刷新自己。同时用 silent 标志防止批量写入时事件被重复派发,从源头避免了”列表渲染两遍、提示弹两次”的问题。
  • 数据源可注入:账单明细模块通过 setSource(fn) 接收筛选结果,而未筛选时回退读全量数据。这样筛选栏和日历/明细天然联动,各模块不用互相知道对方内部实现,只依赖约定接口通信。

最终由 app.js 统一编排刷新顺序和月份状态,保证”概览、图表、日历”三处始终同一个月、同一份数据。

关键代码:

// storage.js —— 统一事件派发,silent 去重
var silent = false;   // 内部批量写入时临时关闭事件,避免重复触发

function emit(action, payload) {
  if (silent) return;
  var doc = global.document;
  if (!doc || typeof doc.dispatchEvent !== 'function') return;
  var detail = { action: action, payload: payload || null };
  try {
    var evt = new global.CustomEvent('ledger:updated', { detail: detail });
    doc.dispatchEvent(evt);
  } catch (e) {
    console.warn('[storage] ledger:updated 事件派发失败', e);
  }
}
// app.js —— 由 S1 统一编排刷新,避免各模块重复渲染
function refreshAll() {
  if (Ledger.Filter && typeof Ledger.Filter.refresh === 'function') {
    Ledger.Filter.refresh();   // 触发 onChange → applyFilter → 列表刷新
  } else {
    state.filtered = null;
    if (Ledger.Records) Ledger.Records.refresh();
  }
  renderSummary();
  renderCharts();
}

// 数据变化统一刷新;新增账单时自动跳到那笔账单的月份与日期
function onLedgerUpdated(event) {
  var detail = (event && event.detail) || {};
  if (detail.action === 'add' && detail.payload && detail.payload.date) {
    var date = String(detail.payload.date);
    setMonth(date.slice(0, 7));
    if (Ledger.Records && typeof Ledger.Records.selectDate === 'function') {
      Ledger.Records.selectDate(date);
    }
  }
  refreshAll();
}
// records.js —— 数据来源优先级:注入的 source 优先,否则读全量
function getRecords() {
  if (typeof source === 'function') {
    var fromSource = source();
    return Array.isArray(fromSource) ? fromSource.slice() : [];
  }
  var records = global.LedgerStorage.getRecords();
  return Array.isArray(records) ? records : [];
}

Records.setSource = function (fn) {
  source = typeof fn === 'function' ? fn : null;
  return Records.refresh();
};
// app.js —— S1 把筛选结果注入列表
Records.setSource(function () {
  return state.filtered === null ? allRecords() : state.filtered;
});

亮点三:面向数据安全与健壮性的设计

设计说明:

项目在对此的考虑集中体现在存储层和导入导出模块:

  • 存储不可用自动降级:detect() 先惰性检测 localStorage 是否可用,不可用则自动切换到内存模式,并在页面顶部明确提示”账单只在本次打开有效”,而不是让用户误以为已保存。
  • 数据损坏先备份再恢复:如果本地存的 JSON 解析失败(比如被篡改或写入中断),会先把原始字符串备份到 ledger_records_broken_<时间戳> 再返回空,绝不静默覆盖用户数据。
  • 脏数据逐条跳过:读取时用非严格模式 buildRecord 逐条校验,一条坏记录被跳过,而不是让整个列表崩掉;对外 getRecords() 返回的是副本,外部误改也不会污染存储。
  • 导入严格校验、失败不覆盖:导入会逐层校验外壳(app/version/records)和每一条记录的每个字段,任何一项不合法都直接拒绝,原有数据一条不动,通过后才二次确认覆盖。
  • 测试不污染真实数据:自动化测试运行前快照备份本地数据,用 try/finally 保证即使测试中途抛错也能原样恢复。

关键代码:

// storage.js —— 存储不可用自动降级
function detect() {
  if (available !== null) return available;
  try {
    var probe = '__ledger_probe__';
    global.localStorage.setItem(probe, '1');
    global.localStorage.removeItem(probe);
    available = true;
  } catch (e) {
    available = false;
    lastError = e;
    console.warn('[storage] localStorage 不可用,已降级为内存模式(刷新后数据会丢失)', e);
  }
  return available;
}
// storage.js —— 数据损坏先备份再返回空,兼容旧格式
function readRaw() {
  if (!detect()) return MEMORY.slice();
  var raw = null;
  try {
    raw = global.localStorage.getItem(KEY);
  } catch (e) { lastError = e; return []; }
  if (!raw) return [];

  var parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (e) {
    // 数据损坏:先备份原文再返回空,避免用户数据被静默覆盖
    lastError = e;
    try { global.localStorage.setItem(KEY + '_broken_' + Date.now(), raw); } catch (ignore) {}
    console.error('[storage] 本地数据不是合法 JSON,已备份到 “' + KEY + '_broken_*”', e);
    return [];
  }
  if (Array.isArray(parsed)) return parsed;
  if (parsed && Array.isArray(parsed.records)) return parsed.records;  // 兼容旧格式
  return [];
}
// storage.js —— 脏数据逐条跳过 + 对外返回副本防污染
function readClean() {
  var raw = readRaw();
  var out = [];
  for (var i = 0; i < raw.length; i++) {
    var rec = buildRecord(raw[i], false);   // 脏数据跳过,不让一条坏数据搞崩整个列表
    if (rec) out.push(rec);
  }
  out.sort(function (a, b) {
    if (a.date !== b.date) return a.date < b.date ? 1 : -1;
    return b.createdAt - a.createdAt;
  });
  return out;
}

function getRecords() {
  return readClean().map(clone);   // 返回副本,调用方改了不污染存储
}
// export.js —— 导入严格校验、失败不覆盖原数据
function validateRecord(item, index) {
  var at = '第 ' + (index + 1) + ' 条记录';
  if (!item || typeof item !== 'object' || Array.isArray(item)) return at + '不是合法对象';
  if (item.type !== 'income' && item.type !== 'expense') return at + ' type 只能是 income / expense';
  if (typeof item.amount !== 'number' || !isFinite(item.amount) || item.amount <= 0)
    return at + ' amount 必须是大于 0 的数字';
  if (!/^\d+(\.\d{1,2})?$/.test(String(item.amount))) return at + ' amount 最多两位小数';
  if (!item.category || typeof item.category !== 'string') return at + ' category 不能为空';
  if (!isValidDate(item.date)) return at + ' date 必须是 YYYY-MM-DD 的真实日期';
  if (item.note !== undefined && item.note !== null && typeof item.note !== 'string')
    return at + ' note 必须是字符串';
  return '';
}

function parseBackup(text) {
  var data;
  try { data = JSON.parse(text); } catch (err) { throw new Error('不是合法的 JSON 文件'); }
  if (!data || typeof data !== 'object' || Array.isArray(data))
    throw new Error('文件结构不正确,应为本系统导出的备份对象');
  if (data.app !== APP_ID) throw new Error('不是「个人记账本」导出的备份(缺少 app 标记)');
  if (String(data.version) !== APP_VERSION) throw new Error('备份版本不兼容:需要 version = ' + APP_VERSION);
  if (!Array.isArray(data.records)) throw new Error('文件里缺少 records 数组');
  if (data.records.length === 0) throw new Error('备份文件里没有任何账单');
  for (var i = 0; i < data.records.length; i++) {
    var message = validateRecord(data.records[i], i);
    if (message) throw new Error(message);
  }
  return { records: data.records };
}
// tests.js —— 运行前快照备份、try/finally 保证恢复,不污染真实数据
function _snapshotData() {
  if (typeof localStorage !== 'undefined') {
    try { return { raw: localStorage.getItem(_STORE_KEY) }; } catch (e) {}
  }
  if (_STORAGE.getRecords) {
    try { return { api: _STORAGE.getRecords() }; } catch (e) {}
  }
  return null;
}

function _restoreData(snap) {
  if (!snap) return;
  if (Object.prototype.hasOwnProperty.call(snap, 'raw')) {
    if (typeof localStorage === 'undefined') return;
    try {
      if (snap.raw === null) localStorage.removeItem(_STORE_KEY);
      else localStorage.setItem(_STORE_KEY, snap.raw);
    } catch (e) {}
  } else if (snap.api && _STORAGE.saveRecords) {
    try { _STORAGE.saveRecords(snap.api); } catch (e) {}
  }
}

function runAllSuites() {
  RESULTS.length = 0;
  const snap = _snapshotData();
  try { Object.keys(SUITES).forEach(s => SUITES[s].forEach(_runOne)); }
  finally { _restoreData(snap); }        // 即使测试抛错也恢复
  return renderResults();
}

六、 团队协作与事件复盘

事件一:接口数据契约不一致引发的重构

  • 过程:开发中期,S2 提交的金额字段为字符串,导致 S4 在计算时发生字符串拼接。
  • 解决:S13(组长)紧急在《开发规范.md》中冻结了《统一数据结构》,强制规定 amount 必须为 Number。全员重新按规范修改,避免了后续更大的联调灾难。

事件二:测试模块的“数据污染”危机

  • 过程:S9 在测试时直接清空了 LocalStorage,导致本地真实账单丢失。
  • 解决:组长在 Code Review 时直接驳回。要求 S9 改写测试逻辑,测试前备份原数据,并在 finally 中恢复原数据。

事件三:分支合并与代码冲突演练

  • 过程:13 人同时开发,多个 PR 同时提交时,出现了 index.html 与 style.css 的代码冲突。
  • 解决:团队强制执行“主分支保护”策略,禁止直接向 main 提交,所有改动必须在各自的 feature 分支完成后,发起 PR 由组长审查合并,有效解决了代码覆盖问题。

七、 每位组员的编程体验

S1(前端负责人):负责页面框架与模块挂载,让我深刻体会到了 DOM 结构规范的重要性。预留清晰的容器 ID,是保障 13 人并行开发时互不干扰的基础。

S2(记账表单):在实现表单校验时,处理各种非法输入(如负数、错误日期)锻炼了我的边界思维。这让我明白,严谨的前端校验是保障系统数据质量的第一道防线。

S3(账单列表):通过监听全局事件来渲染列表,让我直观地感受到了“数据驱动视图”的便利。避免了多个模块互相手动调用刷新函数,降低了代码的耦合度。

S4(财务计算):在开发财务汇总功能时,踩到了 JavaScript 浮点数精度丢失的坑。最终采用“整数分”进行底层计算,让我认识到在涉及金融数据的场景中,底层数据结构必须极其严谨。

S5(筛选查询):处理多条件组合筛选(日期、金额、类别等)让我熟练掌握了数组的高阶函数。如何用简洁的逻辑覆盖所有边界条件,是这次开发中最大的收获。

S6(统计图表):负责将原始数据转换成图表库所需的格式,让我体会到了数据可视化中“数据清洗与格式转换”的重要性。看着枯燥的数字变成直观的折线图,很有成就感。

S7(数据存储):作为底层数据枢纽,通过封装统一接口并引入事件派发(ledger:updated),我深入理解了“发布-订阅”模式对系统解耦的巨大作用。

S8(导入导出):在编写数据导入的安全校验逻辑时,我认识到异常处理的重要性。确保导入失败绝不覆盖原数据,是保障用户数据安全的核心底线。

S9(测试负责人):编写自动化测试用例并落实“前置备份、后置恢复”的数据保护机制,让我明白测试不仅要找出 Bug,更要保证测试过程本身的安全性。

S10(UI 体验优化):处理响应式布局和统一样式时,与其他模块的样式覆盖问题让我学到了 CSS 命名规范(如 BEM)的重要性。良好的 UI 体验需要克制和统一。

S11(GitHub 与文档):负责编写 README 与维护协作规范,让我深刻认识到“接口契约”在团队开发中的决定性作用。清晰的数据结构和分支规范,能大幅降低 13 人的沟通成本。

S12(演示与发布):准备演示数据和版本发布的过程,让我从“用户视角”重新审视了软件。一份真实、有逻辑的演示数据,是展示系统价值的关键。

S13(项目总负责人):作为统筹者,我体会到了接口审查与代码合并的挑战。通过强制推行主分支保护和 PR 流程,我们成功实现了多人并行开发且未发生严重的代码丢失。

八、 PSP 时间统计

注:本次现场编程预估总时长为 4 小时(240 分钟)。但在实际操作中,由于 13 人并行开发的沟通成本、接口不一致导致的代码重构、合并冲突解决以及最终的文档沉淀,实际总耗时有所延长。

阶段 具体事项 预估耗时(分钟) 实际耗时(分钟)
计划 整体规划与团队组建 15 15
· 估计 · 拆解需求、确定技术栈(纯前端+LocalStorage)、群内分工与建库 15 15
开发 核心研发与协同 165 220
· 需求分析 · 梳理业务闭环、确定核心数据结构(JSON 契约)与文件目录结构 15 15
· 生成设计文档 · 编写 README 基线、制定接口规范、设计事件驱动解耦方案 25 30
· 设计复审 · 组长与各模块负责人核对接口命名、确认防精度丢失方案(整数分计算) 15 20
· 代码规范 · 制定 Git 分支策略(feature/develop)、强制 PR 流程与 Commit 规范 15 15
· 具体设计 · 设计 UI 布局(表单、日历、图表)、S9 测试用例设计与数据防污染机制 20 25
· 具体编码 · 各模块并行编码、S11 持续更新 README 文档与架构说明 45 55
· 代码复审 · 组长审查 PR、驳回违规提交;解决多分支合并冲突与测试污染隐患 20 40
· 测试 · 在本地跑通 Storage 自动化测试用例、S11 协同验证主流程闭环 10 20
报告 总结与交付 60 75
· 测试报告 · 汇总 Bug 记录、整理 20 项自动化测试结果与缺陷追踪 10 15
· 计算工作量 · 统计 GitHub 各成员 Commit 记录、PR 贡献度及代码量 10 10
· 事后总结 · 复盘研发过程中的接口冲突事件、录制 GIF 动图、撰写与排版博客 40 50
合计 240 310

九、 GitHub 仓库与协作记录

十、组员评分

成员 系数
S13林诗怡 1.06
S10王恩嘉 1.05
S2 谢慧彬 1.04
S11张润 1.03
S12赖彦然 1.02
S8陈薇薇 1.01
S1 阿合加依娜尔 1.00
S3 王凯凤 0.99
S9 松格拉 0.98
S6 李青容 0.97
S5 刘翠华 0.96
S7 姜思源 0.95
S4 吴佳仪 0.94

posted @ 2026-11-07 22:57  fswtkb  阅读(0)  评论(0)    收藏  举报