现场编程
现场编程:个人记账本系统 项目总结
| 项目 | 内容 |
|---|---|
| 作业所属课程 | 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 等构建工具或后端数据库。
- 启动方式:
- 克隆或下载仓库后,直接双击根目录下的
index.html即可运行体验。 - 若需运行自动化测试,请在根目录执行
python -m http.server 8000,并访问http://localhost:8000/tests/test-runner.html。
- 克隆或下载仓库后,直接双击根目录下的
三、 软件运行截图与视频
1. 核心业务界面
功能展示说明:
顶部仪表盘:
实时展示当月及累计的收支与结余,数据随记录变动动态重算。
左侧记账区:
支持收/支切换,提供快捷金额标签与分类图标,交互流畅。
右侧数据可视化 :
利用第三方图表库实现了“收支对比柱状图”、“分类占比环形图”与“每日收支趋势折线图”。
底部高级查询与日历 :
支持收支类型、分类、日期区间、金额区间等多条件组合筛选;左侧更设计了直观的日历视图,方便用户按天核对账单。

演示视频:
“记一笔”-收入:

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

导出数据

编辑账单

按月查看收支情况

四、 关键代码与设计说明
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 仓库与协作记录
- GitHub 仓库地址:[https://github.com/Annabelle-Lin/personal-ledger]
- Release 版本截图:
![1]()
十、组员评分
| 成员 | 系数 |
|---|---|
| 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 |


浙公网安备 33010602011771号