2026秋软工实践 FitPulse——现场编程·个人记账本
| 项目 | 内容 |
|---|---|
| 作业所属课程 | 202601软件工程 |
| 作业要求 | 2026秋软工工程现场编程作业 |
| 作业的目标 | 在3小时内以团队协作方式完成一个个人记账本Web应用,实践GitHub多人协作、模块化开发、现场演示等软件工程核心技能 |
| 团队名称 | FitPulse |
| 团队成员名字-学号 | 罗炜-102401317(组长)、林复彬-102401527、陈加豪-102401529、陈辉腾-102401521、张智轩-102401121、林祺皓-102401119、李江炜-102401222、罗堉楠-102401218、连向薇-102401203、马锦城-102401328、徐心铭-102401531 |
一、组员职责分工
| 编号 | 成员 | 学号 | 负责模块 | 具体文件 |
|---|---|---|---|---|
| 1 | 罗炜(组长) | 102401317 | 架构 / 集成 / 发布 / 演示 | app.py、static/js/app.js、static/js/api.js、static/js/mock.js、CONTRACT.md、README.md、.github/ |
| 2 | 林复彬 | 102401527 | 数据存储 | storage.py、tests/test_storage.py |
| 3 | 陈加豪 | 102401529 | 数据模型与校验 | models.py、tests/test_models.py |
| 4 | 陈辉腾 | 102401521 | 记账模块 | routes/records.py、static/js/record.js |
| 5 | 张智轩 | 102401121 | 查看模块 | routes/query.py、static/js/list.js |
| 6 | 林祺皓 | 102401119 | 统计·时间维度 | stats/date.py、static/js/stats-date.js |
| 7 | 李江炜 | 102401222 | 统计·类别维度 | stats/category.py、static/js/stats-category.js |
| 8 | 罗堉楠 | 102401218 | 统计·金额维度 | stats/amount.py、static/js/stats-amount.js |
| 9 | 连向薇 | 102401203 | 图表可视化 | static/js/chart.js、static/vendor/ |
| 10 | 马锦城 | 102401328 | 界面与交互 | templates/index.html、static/css/style.css |
| 11 | 徐心铭 | 102401531 | 导入导出 + 演示数据 | seed.py、routes/io.py、static/js/io.js |
二、程序运行环境
后端
- 语言:Python 3.10+
- 框架:Flask 3.x
- 数据存储:JSON 文件(
data/records.json),UTF-8 编码 - 依赖:
flask>=3.0(Web 框架)、pytest>=8.0(测试)
前端
- 技术栈:原生 HTML/CSS/JavaScript,零框架依赖
- 图表:自研 Canvas 实现(
static/js/chart.js),无第三方图表库 - 兼容性:现代浏览器(Chrome/Firefox/Edge),支持移动端响应式布局
运行方式
git clone https://github.com/power819/bookkeeper.git
cd bookkeeper
pip install -r requirements.txt
python seed.py # 生成演示数据
python app.py # 启动服务 → http://localhost:5000
三、软件运行截图和视频
运行截图
-
记账页面:
-
明细页面:
-
时间统计:
-
分类统计:
-
金额分析:
-
导入导出:
-
GitHub commit记录:
演示视频
https://github.com/power819/bookkeeper/releases/download/v1.0.0/-.-.Microsoft.Edge.2026-11-07.19-15-50.mp4
四、软件实现的关键代码
4.1 架构设计:Flask 工厂 + 蓝图注册(1号·罗炜)
# app.py —— 应用入口,工厂函数统一创建 Flask 实例
def create_app():app = Flask(__name__, static_folder="static", template_folder="templates")# 确保数据目录存在os.makedirs(os.path.join(app.root_path, "data"), exist_ok=True)# 注册 6 个蓝图(records / query / io / stats_date / stats_category / stats_amount)from routes.records import bp as records_bpfrom routes.query import bp as query_bpfrom routes.io import bp as io_bpfrom stats.date import bp as stats_date_bpfrom stats.category import bp as stats_category_bpfrom stats.amount import bp as stats_amount_bpapp.register_blueprint(records_bp)app.register_blueprint(query_bp)# ... 其余蓝图# 全局错误处理:统一返回 {ok: false, errors: [...]} 格式@app.errorhandler(404)def not_found(e):return {"ok": False, "errors": ["资源不存在"]}, 404return app
设计要点:
- 采用 Flask 工厂模式,6 个蓝图各自独立,互不依赖,对应 11 人并行开发
- 全局错误处理统一 JSON 格式,前端只需判断
ok字段即可 - 前端同样采用模块化架构:每个 JS 文件自注册
window.BookApp.views. = { render }
4.2 数据存储:原子写入 + 损坏恢复(2号·林复彬)
# storage.py —— 原子替换防止数据损坏
def _write_atomic(records):"""先写临时文件,再 os.replace 原子替换到正式文件"""fd, tmp_path = tempfile.mkstemp(prefix=".records-", suffix=".tmp", dir=directory)with os.fdopen(fd, "w", encoding="utf-8") as f:json.dump(records, f, ensure_ascii=False, indent=2)f.write("\n")f.flush()os.fsync(f.fileno()) # 确保数据真正落盘os.replace(tmp_path, path) # 原子替换,不会出现半截文件def _recover_from_corruption():"""JSON 损坏时备份为 .bak,重置为空数组,不让程序崩溃"""os.replace(path, path + ".bak")_write_atomic([])return []
设计要点:
os.replace是原子操作:要么旧文件完整,要么新文件完整,不会出现写入一半的情况os.fsync确保在系统崩溃时数据不丢失- JSON 损坏时自动备份为
.bak再重置,保证程序可用性,同时保留原始数据供排查 - 使用
threading.RLock保证并发安全
4.3 数据校验:多层防护(3号·陈加豪)
# models.py —— 校验 + 创建 + 金额精度控制
def validate(payload: dict) -> list[str]:"""全字段校验,返回中文错误列表,无错误返回 []"""errors = []# type 枚举校验if record_type not in ("expense", "income"):errors.append(f"类型「{record_type}」不合法")# 金额用 Decimal 解析,拒绝 NaN/Infinityamount = _parse_amount(raw_amount)if amount is None: errors.append("金额必须为数字")elif amount <= 0: errors.append("金额必须大于 0")# 日期严格 YYYY-MM-DD,strptime 后回写格式化比对防止 2026-1-1if not _is_iso_date(raw_date):errors.append("日期格式必须为 YYYY-MM-DD 且为有效日期")# 类别必须在对应枚举中if category not in ALL_CATEGORIES[record_type]:errors.append(f"类别「{category}」不属于{TYPE_LABELS[record_type]},可选:{options}")return errorsdef new_record(payload: dict) -> dict:"""创建记录:UUID4 主键、金额 Decimal 量化到分、UTC 时间戳"""return {"id": str(uuid.uuid4()),"amount": _round_amount(payload.get("amount")), # Decimal(分)量化,避免浮点 2.675→2.67"created_at": datetime.now(timezone.utc).isoformat(),# ...}
设计要点:
- 金额用
Decimal以"分"为单位量化,避免二进制浮点误差(如2.675四舍五入到2.68而非2.67) - 日期校验先
strptime再回写格式化比对,防止2026-1-1这种非严格格式 - UUID4 作为主键,
created_at由服务端生成,忽略客户端传入的id/created_at(防伪造)
4.4 前端导航:视图注册 + 动态调度(1号·罗炜)
// app.js —— 全局视图调度器
window.BookApp = {views: {}, // 各模块自注册 render(container)state: { currentView: "record" },showToast: function(msg, type) { /* Toast提示 */ },editRecord: function(id) { /* 4号实现的编辑回填 */ },refreshCurrentView: function() { navigateTo(state.currentView); }
};function navigateTo(name) {// 更新导航高亮document.querySelectorAll(".nav-item").forEach(el => {el.classList.toggle("active", el.dataset.view === name);});// 清空容器,调用对应视图的 rendercontainer.innerHTML = "";window.BookApp.views[name].render(container);
}// DOM 就绪后绑定导航点击事件,默认打开记账页
document.addEventListener("DOMContentLoaded", initApp);
4.5 自研图表:零依赖 Canvas 引擎(9号·连向薇)
// chart.js —— 自研 Canvas 图表,约 900 行
// 对外接口(与 CONTRACT.md 契约完全一致)
BookApp.chart.pie(el, data, opts) // 饼图/环形图
BookApp.chart.bar(el, data, opts) // 柱状图
BookApp.chart.line(el, data, opts) // 折线图// 数据归一化:自动兼容 6/7/8 号三种不同字段名
var label = pick(d, 'label', 'period', 'category', 'bucket', 'name', 'x', 'date');
var value = pick(d, 'value', 'amount', 'count', 'total', 'balance', 'expense', 'income', 'y');// 配色经过色盲模拟器校验,相邻色 ΔE 最小 9.1
// 支持 CSS 变量 → 运行时解析 → Canvas,整站换肤只需改 style.css
4.6 其余模块关键代码由各成员自行补充
4.6 数据存储——原子写入与损坏恢复(2号·林复彬)
# storage.py —— 核心:先写临时文件再原子替换
def _write_atomic(records):"""把 records 写入临时文件,再 os.replace 原子替换到正式文件"""fd, tmp_path = tempfile.mkstemp(prefix=".records-", suffix=".tmp", dir=directory)with os.fdopen(fd, "w", encoding="utf-8") as f:json.dump(records, f, ensure_ascii=False, indent=2)f.write("\n")f.flush()os.fsync(f.fileno()) # 确保数据真正落盘,不是只到 OS 缓冲区os.replace(tmp_path, path) # 原子替换:新旧数据不会同时出现半截
设计要点:
- 所有写操作都走
_write_atomic,用tempfile.mkstemp + os.fsync + os.replace三步保证:要么旧文件完整,要么新文件完整,永远不会有半截 JSON load_all()发现 JSON 损坏时,自动把坏文件备份成.bak,再重置为空数组——程序不会因为数据文件损坏而崩溃- 所有读写操作都用
threading.RLock保证并发安全,避免两个请求同时写文件时互相覆盖 - 写入了 4 个 pytest 测试:空文件、正常读写(append/update/delete)、损坏恢复,全部通过
4.7 数据模型与校验——多字段中文错误提示(3号·陈加豪)
# models.py —— 金额用 Decimal 量化到分,拒绝 NaN/Infinity
def _parse_amount(value):"""把金额解析为 Decimal,拒绝 bool、NaN、Infinity"""if isinstance(value, bool): return None # bool 是 int 子类,必须优先判断if isinstance(value, float) and not math.isfinite(value):return Noneif isinstance(value, float):return Decimal(str(value)) # 用 str 转换,避免二进制浮点误差# ...def validate(payload: dict) -> list[str]:"""全字段校验,返回中文错误列表,所有字段一次查完"""# type 枚举 → amount > 0 → 日期 YYYY-MM-DD 严格格式 → category 枚举匹配 → note ≤ 100 字# 即使 5 个字段全错,也一次性返回 5 条中文错误,不让用户反复提交反复报错
设计要点:
- 金额统一用
Decimal处理,四舍五入到"分"(quantize(CENT)),规避了2.675 → 2.67这类二进制浮点坑 _parse_amount显式拒绝bool(它在 Python 里是int子类,True会被当成1)、NaN、Infinity- 日期校验先
strptime再回写格式化比对,防止2026-1-1非严格格式通过 new_record()生成 UUID4 主键 + UTC 时间戳,忽略客户端传入的 id/created_at(防伪造)- 写了 10 个 pytest:涵盖全部支出/收入类别枚举、金额参数化测试、备注边界、多字段同时报错
4.8 记账模块——新增/编辑/删除 API + 表单联动(4号·陈辉腾)
# routes/records.py —— POST 新增记录
@bp.route("", methods=["POST"])
def create_record():payload = _get_json_body()errors = validate(payload) # 先校验if errors:return jsonify({"ok": False, "errors": errors}), 400record = new_record(payload) # 再创建(id/created_at 由服务端生成)storage.append(record) # 原子追加落库return jsonify({"ok": True, "record": record}), 201
// record.js —— 支出/收入切换时类别下拉联动更新
container.querySelectorAll(".type-toggle .btn").forEach((btn) => {btn.addEventListener("click", () => {setType(container, btn.dataset.type);renderCategoryOptions(container, true); // 类别下拉跟着变,保留已选值});
});
设计要点:
- 编辑回填功能:5号在列表页点"编辑"→ 调用
BookApp.editRecord(id)→ 切换到记账页 → 表单自动加载原记录 → 类别下拉按原类型初始化 → 提交走 PUT 而非 POST - PUT 更新时把 patch 合并进原记录再校验:
merged = {**existing, **payload}→validate(merged)→new_record(merged)做规范化 →id/created_at由服务端保留不变 - 前端表单错误展示:后端返回
{errors: [...]}数组,前端逐条渲染带红色背景的错误提示框,提交成功后清空表单+toast 提示
4.9 查看模块——多条件筛选 + 分页查询(5号·张智轩)
# routes/query.py —— 链式过滤 + 排序 + 分页
@bp.route("", methods=["GET"])
def list_records():records = storage.load_all()# 链式过滤:type → category → date_from → date_to → 关键字if record_type in ("expense", "income"):records = [r for r in records if r.get("type") == record_type]# ... 其余过滤器# 排序field, reverse = SORT_RULES.get(request.args.get("sort"), ("date", True))records.sort(key=_sort_key(field), reverse=reverse)# 分页return jsonify({"ok": True, "items": records[start:start+page_size], "total": total, "page": page, "pages": pages})
设计要点:
- 每条 filter 都在上一个 filter 的结果上继续过滤,任意组合都生效(比如同时筛选"支出 + 饮食 + 11月")
- 同一天多条记录按
created_at做二级排序,保证翻页不重不漏 - 前端筛选栏:类型下拉变化时,类别下拉联动重建(支出/收入各有不同类别,不限类型时取并集)
- 删除操作有
window.confirm二次确认,防止误删 - 分页状态放在 JS 闭包内而非 DOM 上——视图切换后回来,筛选条件和页码不丢
4.10 统计·时间维度——日/周/月粒度聚合(6号·林祺皓)
# stats/date.py —— 按粒度分组汇总
def _period(day, granularity):if granularity == "day": return day.isoformat(), day.isoformat()if granularity == "month": return day.strftime("%Y-%m"), day.strftime("%Y-%m")# week:ISO 周,周一为起点,跨年周用周一日期做排序基准iso = day.isocalendar()label = f"{iso.year}-W{iso.week:02d}"week_start = day - timedelta(days=day.weekday())return label, week_start.isoformat()
设计要点:
- 三种粒度(日/周/月)用同一接口、不同
granularity参数区分,前端切换粒度时自动重请求 - ISO 周(周一为起点)处理了跨年周问题:
sort_key用周一日期排序,不会出现2026-W52排在2027-W01后面 - 返回值
[{period, income, expense, balance}],balance = income - expense - 前端同时渲染三张汇总卡片(总收入/总支出/结余)+ 周期明细表 + 柱状图(给9号传
[{label: period, value: expense}])
4.11 统计·类别维度——占比排行 + 饼图数据输出(7号·李江炜)
// stats-category.js —— 支出/收入切换 + 日期筛选
container.querySelector("#category-expense").addEventListener("click", () => {currentType = "expense";loadData(); // 切换类型后自动重新请求
});// 请求成功后将数据派发给 9 号图表模块
const chartData = categoryData.map(item => ({label: item.category, value: item.amount}));
window.BookApp.categoryChartData = chartData;
window.dispatchEvent(new CustomEvent("categoryStatsUpdated", {detail: chartData}));
设计要点:
- 后端按类别汇总 → 计算占比百分比 → 按金额降序排列
- 前端渲染分类占比条形列表(每条有百分比进度条 + 交易笔数)+ Top 5 排行(🥇🥈🥉奖牌图标)
- 通过
CustomEvent("categoryStatsUpdated")把数据传给 9 号的图表模块,9 号监听到事件后自动在列表中插入饼图——跨模块解耦,双方各自独立开发 - 支出/收入切换按钮 + 日期范围筛选,切换后自动刷新
4.12 统计·金额维度——分布区间 + 统计卡 + Top5(8号·罗堉楠)
# stats/amount.py —— 核心纯函数 build_stats,方便单测
BUCKETS = (("0-50", Decimal("0"), Decimal("50")),("50-100", Decimal("50"), Decimal("100")),("100-200",Decimal("100"), Decimal("200")),("200-500",Decimal("200"), Decimal("500")),("500-1000",Decimal("500"),Decimal("1000")),("1000+", Decimal("1000"),None),
)def build_stats(records, date_from=None, date_to=None):"""纯函数:筛选 → 汇总 → max/min → Top5 → 分布区间"""# ... 筛选有效记录 ...total = sum((row["amount"] for row in rows), Decimal("0"))max_row = max(rows, key=lambda row: (row["amount"], row["day"]))# Top5 金额降序,同金额时日期新的在前top_rows = sorted(rows, key=lambda row: (row["amount"], row["day"]), reverse=True)[:5]return {"count": count, "total": _money(total), "max": ..., "min": ..., "avg": ..., "top5": ..., "distribution": ...}
设计要点:
- 6个固定金额区间(左闭右开),含
1000+兜底桶 - 同金额时以日期做 tie-breaker,保证多次查询结果稳定(而不是随机顺序)
- 统计包含收入+支出,不按 type 过滤,金额全部转 Decimal 量化到分
- 空数据时 max/min 返回
null(而非[]或报错),前端判断后显示"—" - 写了 6 个 pytest:日期筛选、分布边界、空数据、非法日期、反向区间、与 seed.py 集成测试
4.13 图表可视化——自研 Canvas 引擎(9号·连向薇)
// chart.js —— 自研 Canvas 图表引擎,约 900 行,零依赖
// 数据归一化:自动兼容 6/7/8 号三种不同字段名
var label = pick(d, 'label', 'period', 'category', 'bucket', 'name', 'x', 'date');
var value = pick(d, 'value', 'amount', 'count', 'total', 'balance', 'expense', 'income', 'y');// 配色经色盲模拟器校验(8 个分类色相邻 ΔE ≥ 9.1)
// 支持暗色模式自动切换 + CSS 变量实时解析
设计要点:
- 放弃原计划的 Chart.js CDN,手写纯 Canvas 实现三类图表(饼/柱/折),不受网络限制
- 数据归一化:
pick()函数按优先级依次尝试 key/value 别名,6/7/8 号直接传接口返回值即可,不用写转换 - Canvas 不认 CSS 变量(
'var(--xxx)'赋值给strokeStyle会静默变黑),需运行时查getComputedStyle解析真值——当时排查了 20 分钟才发现 - 饼图超过 8 个分类自动合并尾部为"其他";柱状图标签密集时自动斜排 + 剥年份前缀;折线图有多系列图例 + 十字准线
- 配色通过了色盲模拟器和对比度校验器的验证
4.14 界面与交互——全局样式 + 响应式 + Toast(10号·马锦城)
/* style.css —— 卡片、表单、表格、按钮、Toast、空状态、移动端适配 */
.navbar { display:flex; position:sticky; top:0; z-index:100; } /* 吸顶导航 */
.card { background:#fff; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,.08); }
.summary-cards { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }/* 移动端适配 */
@media (max-width: 640px) {.navbar { flex-direction:column; }.nav-links { flex-wrap:nowrap; overflow-x:auto; } /* 横向滑动而非换行 */.summary-cards { grid-template-columns: 1fr; }
}
设计要点:
- 统一了全局 CSS 变量管理(
--primary、--danger、--success等),其他模块直接用,改一处整站换肤 - Toast 组件:固定在右上角,同屏最多 4 条防刷屏,2.6s 自动退场带滑出动画
- 移动端适配:导航横向滑动(不换行占高)、表单单列、汇总卡片单列,配合
@media (prefers-reduced-motion)减弱动画 index.html作为唯一挂载点,其他人不得修改——需要挂载点找 10 号统一协调- 为 4/5/7/9/11 号的模块预留了配套 CSS 类(
.badge、.filter-bar、.chart-box、.io-report、.pagination等)
4.15 导入导出 + 演示数据——CSV/JSON 双向兼容 + seed 生成器(11号·徐心铭)
# routes/io.py —— 导出:JSON/CSV 双格式,CSV 默认带 BOM 保证 Excel 不乱码
@bp.route("/export", methods=["GET"])
def export_data():fmt = (request.args.get("format") or "json").strip().lower()if fmt == "csv":buf = io.StringIO()writer = csv.DictWriter(buf, fieldnames=CSV_FIELDS, extrasaction="ignore")writer.writeheader(); writer.writerows(records)payload = "" + buf.getvalue() # BOM(utf-8-sig),Excel 打开中文不乱码return Response(payload, mimetype="text/csv; charset=utf-8", ...)# 导入:自动识别 JSON/CSV,逐行校验 + 去重
def _merge_rows(rows, errors):"""同 type + category + amount + date 视为重复,文件内外都去重"""# ... 逐行 models.validate → 去重 → models.new_record → storage.save_allreturn {"ok": True, "imported": len(imported_records), "skipped": skipped, "errors": errors}
设计要点:
- 导出 JSON/CSV 双格式可选,CSV 自动加 UTF-8 BOM 保证中文在 Excel 中正常打开
- 导入兼容中文表头("类型"/"类别"/"金额"/"日期"/"备注")和中文取值("支出"/"收入"),从 Excel 直接导出的 CSV 也能直接导入
- 重复判定:同 type + category + amount + date 视为重复(四字段联合去重),防重复导入
- 导入忽略客户端传入的 id/created_at,只用业务字段
{type, category, amount, date, note}—— 防伪造主键 seed.py:生成 40 条近 3 个月记录,覆盖全部 13 个类别组合(支出 8 + 收入 5),每月 1 号固定一条工资保证时间线稳定基线,固定随机种子RANDOM_SEED = 20261107让全组生成的数据一致
五、软件亮点
亮点一:3小时内11人并行开发的协作机制
14:00 发布任务,17:00 提交,3小时内完成一个完整的全栈Web应用。
协作流程:
- 14:20 — 组长提交
CONTRACT.md(数据结构 + API表 + 文件归属 + 依赖顺序)+ 11人空壳文件(带函数签名和TODO)+ 每人一条 Issue - 14:20-16:30 — 各人并行开发:2/3号(存储+模型)→ 4/5/11号(CRUD + 导入导出)→ 6/7/8号(三维统计)→ 9号(图表);10号独立提交骨架HTML/CSS
- 每40分钟 — dev 分支合并一次 main,及时化解冲突
- 16:55 — 打 tag v1.0.0,发 GitHub Release
- 17:00 — 提交链接 + 现场演示
关键设计:
CONTRACT.md是"组内的宪法",所有数据结构、API 接口、前端约定都在开工前定死,避免"我以为你返回这个字段"- 空壳文件让每个人一开工就知道自己往哪个文件写什么函数,省掉"我们讨论一下接口"的回合
- 依赖链从底向上:存储→模型→业务→统计→图表,每层装好下层就能独立跑
亮点二:数据安全的多层防护
- 原子写入:
tempfile + os.fsync + os.replace,不会因程序崩溃产生半截 JSON 文件 - 损坏恢复:JSON 解析失败时自动备份为
.bak,返回空数组,不让程序崩 - 金额精度:Decimal 以分为单位四舍五入,彻底避免
0.1 + 0.2 = 0.30000000000000004的浮点问题 - 防伪造:
id和created_at始终由服务端生成,忽略客户端传入值;导入时同样只取业务字段 - 导入去重:同 type + category + amount + date 视为重复,文件内外都去重,返回
{imported, skipped, errors}
亮点三:自研Canvas图表,零第三方依赖
9号(连向薇)放弃原计划的 Chart.js,手写约 900 行 Canvas 实现三类图表:
- 数据归一化:自动兼容 6/7/8 号三种不同字段名的返回结构(
{period, expense}/{category, amount}/{bucket, count}) - 色盲友好:8 个分类色经过色盲模拟器校验,相邻 ΔE ≥ 9.1;收入绿/支出红配对强制搭配图例文字(不应仅靠红绿区分)
- 自动适配:支持暗色模式 CSS 变量、ResizeObserver 自适应容器宽度、柱状图标签自动缩略年份前缀
六、花费时间较长、收获较大的事件
事件一:CONTRACT.md 的制定——"20分钟开会,省了3小时扯皮"
时间:约20分钟(组长起草 + 全组确认)
经过:14:13 发布任务后,我(组长)意识到如果11个人各写各的,最后拼起来一定对不上——字段名不一致、API路径不一样、前端不知道怎么调后端。所以我在14:20前写完了 CONTRACT.md,定义了:
- 核心数据结构(Record 的 7 个字段及类型)
- 10 个 API 接口的路径、请求体、返回格式
- 前端 JS 命名空间约定(
BookApp.views、BookApp.api、BookApp.chart) - 11 人文件归属清单 + 依赖顺序时间线
收获:这份文档成了全组的"宪法"。整个开发过程中,没有出现过一次"你这个接口返回的字段名不对"的沟通——因为开工前就已经定好了。后续即使有人调整实现细节,数据结构始终和 CONTRACT.md 一致,合并冲突也少了很多。
事件二:Canvas 静默失败——"画错了不报错,颜色黑了20分钟"
时间:约20分钟
经过:9号(连向薇)在写图表模块时,给系列色传了 'var(--viz-income)' 这个 CSS 变量字符串,想让 Canvas 的颜色跟着 style.css 走。结果整条折线变成黑色的,还一段段断开。排查了将近20分钟才发现原因:Canvas 的 strokeStyle 被赋一个非法值(CSS 变量字符串)时不会抛错,也不会警告,直接静默保持上一次的颜色(默认黑)。更隐蔽的是标记点,fillStyle 同样失效,但后面画的 2px 白色描边正常——于是每个标记点都在黑线上擦出一个小缺口,看起来像虚线。
收获:Canvas API 几乎不会报错,画错了就是画错了,必须真的把图渲染出来用肉眼检查。最终写了一个颜色解析函数,对 var(--xxx) 格式的字符串主动查 getComputedStyle 取真值。这个教训适用于所有 Canvas 编程场景。
事件三:视图切换时的状态管理——"切换视图后筛选条件丢了"
时间:约15分钟
经过:5号(张智轩)的明细页有筛选栏(类型/类别/日期/关键字),用户在"明细"页设置了筛选条件,切到"记账"页记一笔,再切回"明细"——筛选条件全丢了,回到了默认状态。原因是 app.js 的 navigateTo 每次调用 container.innerHTML = "" + 重新 render(),相当于把整个视图连同内部状态一起销毁再重建。
收获:解决方案是在 5 号的 list.js 中把分页状态(state = {page, pageSize})放在模块闭包内而非 DOM 上。DOM 可以销毁重建,但 JS 模块只加载一次,闭包里的状态就保住了。这体现了"状态与视图分离"的经典原则——DOM 是无状态的渲染结果,JS 闭包才是真相的来源。
七、每位组员的团队编程体验
1号·罗炜(组长)
我的模块关键代码:作为组长,我负责了整个项目的骨架搭建。在14:20前我提交了 CONTRACT.md、目录结构、全部空壳文件(带函数签名和TODO)和每人一条 GitHub Issue,这样11个人一开工就知道自己的文件在哪里、函数签名是什么、上游下游是谁。
app.js 的视图调度器是整个前端的"神经中枢"——6个功能模块各自注册 {render(container)},导航切换时调度器负责清空容器、调用对应 render、更新高亮。api.js 封装了 get/post/put/del 四个 fetch 函数,统一了错误处理(res.ok 为 false 时抛出带 errors 数组的 Error),并预留了 USE_MOCK 开关让各模块可以先拿假数据跑通。
团队编程体验:最大的感受是"约定优于配置"。开工前多花20分钟写 CONTRACT.md,后续3小时几乎没有因为接口不一致而沟通——因为所有字段名、API路径、返回格式都已经在文档里定死了。空壳文件也让每个人一开工就能写代码,不用等别人。
另一个体会是集成比想象的耗时。每40分钟合并一次 dev→main,虽然有 CI 检查但合并冲突是免不了的。最典型的是 index.html 和 app.py——这两个文件被多人同时修改,每次合并都要小心处理。后来10号在 HTML 里加了明确的注释段(),冲突才少了。
2号·林复彬(数据存储)
我的模块是 storage.py,负责所有数据的存取。虽然只有 146 行,但它是全组的"地基"——3/4/5/6/7/8/11 号都需要通过我来读写数据,如果我崩了,整个应用就崩了。
一开始我写的 save_all 是直接覆盖写入 JSON 文件的。组里提醒说这样有风险——如果写入过程中程序崩溃(或者刚好有人在读),data/records.json 可能只剩半截。于是我加上了原子写入:先写到临时文件、fsync 确保落盘、再用 os.replace 原子替换。这样无论何时,数据文件要么是旧版本,要么是新版本,永远不会是半截。
另一个设计是 JSON 损坏恢复。起初我认为"数据文件不会损坏直接读就行",但加了测试后才发现:哪怕是一次手动编辑 JSON 时少敲了一个逗号,整个应用就不能用了。改成自动备份 .bak + 重置空数组之后,心里踏实多了。一个教训:永远不要假设数据文件是完好的。
3号·陈加豪(数据模型与校验)
models.py 看起来简单——定义几个枚举、写个校验函数——但细节非常多。最坑的是金额校验:Python 里 bool 是 int 的子类,True 会被 isinstance(True, int) 判断为 True,如果不用 isinstance(value, bool) 先拦一道,True 就能伪装成金额 1 通过校验。另一个是浮点 NaN:float('nan') > 0 返回 False,但 float('nan') <= 0 也返回 False,所以我在 _parse_amount 里加了 math.isfinite 显式拦截。
日期校验也花了不少时间。我一开始用 datetime.strptime(value, "%Y-%m-%d"),结果发现 2026-1-1(月份未补零)也能解析成功。为了让格式严格一致,我在解析后回写格式化再比对一次——解析结果必须是"YYYY-MM-DD"严格格式才算通过。
最让我满意的设计是 validate() 一次返回所有错误。用户提交一条记录时可能 5 个字段全错,如果只报第一个错,用户要反复改反复提交反复报错,体验很差。现在一口气列出全部问题,一次修完。
4号·陈辉腾(记账模块)
我同时负责后端 API(routes/records.py)和前端表单(static/js/record.js),两边都要写。最初觉得新增/编辑/删除三个接口很常规,但"编辑"功能比预想的复杂很多:
-
PUT 接口:不能直接把请求体当新记录落库——用户可能只传一个
{"amount": 20}来改金额,其他字段都不传。所以我先把 patch 合并进原记录({**existing, **payload}),再走完整校验、再用new_record()做规范化(金额两位小数、备注去空白),最后才写入。id 和 created_at 始终保持不变。 -
编辑回填:5号在明细列表里点"编辑",我的前端需要切到记账页、自动加载原记录、把类别下拉按原类型初始化、表单标题从"记一笔"变成"编辑记录"、提交时走 PUT 而非 POST。这个跨模块配合是 CONTRACT.md 里的约定(
BookApp.editRecord(id)),因为提前定好了,接起来没有踩坑。 -
支出/收入切换时类别联动:支出有 8 个类别、收入有 5 个(含"红包"),切换类型时下拉框要跟着变,但如果用户已经选了一个类别、而这个类别在新类型中也存在(比如"其他"),就保留不重置。
5号·张智轩(查看模块)
我负责后端查询接口和前端明细列表页。查询接口看着简单——从 storage.load_all() 取全量、链式过滤、排序、分页——但翻页的正确性花了不少心思。
最主要的问题是同一天多条记录:如果只按 date 排序,同一天内的顺序不固定,翻页时可能出现"第 4 页的最后一条和第 5 页的第一条是同一条记录"。解决方法是二级排序:先按 date,同一天再按 created_at(精确到秒)排。这样每一次请求的排序结果都是确定的,翻页不会漏也不会重。
前端方面,我把分页状态(state = {page, pageSize})放在 JS 模块的闭包里,而不是存在 DOM 上。因为 app.js 切视图是 container.innerHTML = "" 整块销毁重建的——如果状态存在 DOM 上,切走再回来时筛选条件、页码全丢了。放在闭包里,只要页面不刷新,状态就一直保留。
还有一个细节:类型下拉变化时,类别下拉联动重建(支出/收入各有不同类别枚举),但原来已经选的值如果在新列表中存在就保留——这样用户改类型但类别还在时不用重新选。
6号·林祺皓(统计·时间维度)
我的模块按日/周/月三种粒度汇总收入支出。核心逻辑是"把记录扔进对应的时间桶里再求和"——这件事本身不难,但 ISO 周的跨年问题坑了我一把。
date.isocalendar() 返回 (year, week, weekday),但 2026 年 12 月 28 日(周一)所在的周可能跨到 2027 年,isocalendar() 返回 (2027, 1, 1)。如果我直接按 (year, week) 排序,2026-W52 会排在 2027-W01 后面,但按时间顺序2026-W52 应该在前。解决方法是加一个 sort_key——用该周的周一日期做排序键,这样跨年周也能正确排列。
前端方面,我提供了三张汇总卡片(总收入/总支出/结余)+ 周期明细表 + 给 9 号的图表数据。默认加载当月第一天到今天的范围,粒度变化或日期范围变化自动重新请求。
7号·李江炜(分类统计模块)
我负责分类维度的收支统计。后端按类别汇总金额、计算占比百分比、降序排列;前端渲染分类占比条形列表 + Top 5 排行。
我做的最有意思的一件事是和 9 号(图表模块)的对接。我的接口返回 [{category, amount, percent, count}],但 9 号的图表认的是 [{label, value}] 格式。我本可以在我这边写个转换函数适配 9 号,但那样我和 9 号就耦合了——如果后续有人写新图也来找我改,我不可能把各模块的格式都适配一遍。
我改成了通过 window.dispatchEvent(new CustomEvent("categoryStatsUpdated", {detail: chartData})) 派发事件,把转换后的数据广播出去。9 号只需要监听这个事件、拿到数据就往列表里插图表容器再画图。这样我不用知道 9 号怎么画图,9 号不用知道我的接口长什么样——中间只有一层事件桥接。
8号·罗堉楠(金额维度统计)
我负责金额分布统计:总金额/笔数/平均值/最大值/最小值 + 六个金额区间的分布 + Top 5 记录列表。
这个模块的数据处理量最大(统计 40 条展示数据时需要遍历全部记录),所以我刻意把核心逻辑 build_stats() 写成了纯函数——输入 records + 日期范围,输出统计结果 dict,不读全局状态、不写文件。好处是:
- 单元测试不需要 mock 任何东西,直接传一组记录进去比对输出即可;
- 出了问题(比如金额对不上),只要看这个函数就够了,不用跟存储层交叉排查。
金额分布区间(0-50、50-100、100-200、200-500、500-1000、1000+)的设计参考了日常生活的小额高频+大额低频分布,6 个桶刚好够用一个 2×3 的柱状图展示。同金额时按日期做 tie-breaker,保证每次统计结果的 Top 5 顺序一致。
前端我写了两套渲染:优先用 9 号的 Chart.bar 画柱状图,如果 9 号还没实现就降级成纯 CSS 的条形图(.sa-bar-fill)。这样在任何时候统计页都能正常工作,不依赖图表模块的上线时间。
9号·连向薇(图表可视化)
我负责图表模块 static/js/chart.js,原计划是下载 Chart.js 到 static/vendor/,但考虑到现场网络环境不稳定,加上 Chart.js 的 window.Chart 会和我们的接口名冲突,最终选择了纯手写 Canvas 实现,约 900 行。
为什么不用 Chart.js:现场断网 CDN 就挂了;window.Chart 被 Chart.js 占掉之后我们约定的 Chart.pie() 就没法用了;而且 Chart.js 的配置项很复杂,6/7/8 号每人去学一套配置写法还不如我这边统一做数据归一化。
Canvas 的两个坑:
- CSS 变量静默失败:我给
strokeStyle传了'var(--viz-income)'想让颜色跟着style.css走,结果整条折线是黑的——Canvas 不会报错,非法值直接保持上一次颜色。修法是所有颜色统一过一道col()函数解析真值。 - 画布撑大容器:Canvas 有 2:1 固有宽高比,当 width 100% 而 height 继承 auto 时,浏览器按比例反推高度,容器从 240px 涨到 440px。修法很简单,但顺序很关键——先把容器高度量下来,再把画布插进去。
配色我也没有凭感觉挑——用校验器跑了色盲模拟和对比度,确认 8 个分类色相邻 ΔE ≥ 9.1 才定下来。收入绿和支出红色盲 ΔE 只有 7.2,属于"必须配文字"的区间,所以代码里强制 ≥2 系列必有图例。
10号·马锦城(界面与交互)
我负责 index.html 骨架和 style.css 全局样式。HTML 作为唯一的挂载点,其他任何人不得修改——我需要提前和全组确定每个模块需要什么容器、什么 ID、什么 CSS 类,然后一次性写进去。
CSS 方面我定了几个核心理念:
- CSS 变量统一配色(
--primary、--danger等),其他模块引用变量而非硬编码色值——后续改色只需改:root一处; - 移动端优先:导航在手机上不换行占高,而是横向滑动;表单单列、卡片 1 列、微调字号;
- 无障碍兜底:
@media (prefers-reduced-motion)关闭所有动画,导航有aria-label和role标注,错误提示用aria-live="polite"供屏幕朗读器。
最难调试的是 Toast 通知和视图切换过渡的配合。视图切换时 container.innerHTML 整块替换,如果 Toast 挂在那里面也会一起被清掉。所以我把它放在 index.html 的独立 #toast-container 里(position:fixed),完全脱离视图容器——无论切到哪个视图,Toast 始终显示在右上角。
11号·徐心铭(导入导出 + 演示数据)
我负责的两件事看似独立,其实互相依赖——导入导出的格式必须和 seed.py 生成的格式一致,否则组里一边用 seed 生成数据一边导出 CSV 发现对不上就麻烦了。
seed.py:生成 40 条近 3 个月记录,覆盖全部 13 个类别组合。关键设计是固定随机种子(RANDOM_SEED = 20261107),全组任何人跑 python seed.py 得到的数据完全一致——这样 6/7/8 号写统计时看到的结果和我开发时一样,不会出现"我这边的数据跟你不一样"的沟通成本。每月 1 号固定一条工资,保证时间维度统计有稳定的收入基线。
导入导出:导出支持 JSON/CSV 双格式。CSV 导出时我特意加了 UTF-8 BOM("" 前缀),否则 Excel 直接打开中文 CSV 全是乱码。导入时的兼容性比导出复杂得多——要同时支持 JSON、CSV、中文表头("类型"/"支出")、英文表头("type"/"expense")四种输入。重复判定用 type + category + amount + date 四字段联合去重,防止同一笔记录被重复导入。
导入上传文件必须用 FormData + fetch,不能用 api.js 的 post(它固定 Content-Type: application/json),否则文件会被当 JSON 解析。这里是和 1 号 api.js 的一个接口差异点——如果将来 api.js 支持文件上传,这边的代码可以简化。
八、GitHub 仓库
仓库地址:https://github.com/power819/bookkeeper
Commit 记录(截至17:00)
4ac9898 chore: add render.yaml for deployment
d85b66d Update README.md
7bf5de4 Update README.md
a305a01 Merge pull request #22
ff05a2d Merge branch 'main'
4a4b16a Merge pull request #21
cf7496b Merge pull request #20 (feat/stats-amount)
d50c23a Update category.py
98e12e4 fix(chart): 柱状图标签密集时自动缩略
8c404f9 Merge pull request #18
7d950f6 Merge pull request #19
11926f3 feat(stats): 时间维度统计
cca8467 feat(stats): 类别维度统计
e3c6267 feat(chart): 自动接管7号的分类统计数据
c47b567 Merge pull request #17
400d0e0 Merge pull request #16 (feat/5-query-list)
e7f620d feat(stats-amount): 对接存储与seed的金额维度统计
5620764 feat(5号): 明细查询接口 + 列表页筛选分页
九、SP 参考
- 构建之法——团队编程的五个阶段
附:协作过程记录表
| 时间段 | 事件 | 参与人 |
|---|---|---|
| 14:00 | 任务发布,组长建仓库 | 罗炜 |
| 14:13 | 在群里发布分工表 + 任务卡 | 罗炜 |
| 14:20 | 提交 CONTRACT.md + 目录结构 + 空壳文件 + 11条Issue | 罗炜 |
| 14:20-14:45 | 2/3/10号并行开发(存储、模型、界面骨架) | 林复彬、陈加豪、马锦城 |
| 14:45-15:15 | 4/5/11号并行开发(记账CRUD、明细查询、导入导出+seed) | 陈辉腾、张智轩、徐心铭 |
| 15:00 | seed.py 提交,生成40条演示数据 | 徐心铭 |
| 15:15-15:50 | 6/7/8号并行开发(三类统计) | 林祺皓、李江炜、罗堉楠 |
| 15:50-16:30 | 9号图表模块集成 + bug修复 | 连向薇 |
| 16:30-16:50 | 全组交叉review + 修bug + README | 全员 |
| 16:55 | 打 tag v1.0.0,发 Release | 罗炜 |
| 17:00 | 提交 GitHub 链接 | 罗炜 |
| 17:00-17:15 | 现场演示 | |
| 17:00-23:00 | 撰写博客 | 全员 |