个人记账与预算洞察
源自想法 B000008:本地优先记账,收支分类、月度统计与预算趋势,数据存于浏览器。
本月收入0.00
本月支出0.00
结余0.00
| 日期 | 类型 | 分类 | 金额 | 备注 |
|---|
代码示例:月度聚合与预算进度
js
/* ledger-core.js —— 纯逻辑,脱离 DOM,可直接被 Node 引用与测试 */
function summarize(records, month) {
var income = 0, expense = 0;
(records || []).forEach(function (r) {
if (String(r.date || "").slice(0, 7) !== month) return; // 只看当月
if (r.type === "income") income += Number(r.amount) || 0;
else expense += Number(r.amount) || 0;
});
return { income: income, expense: expense, balance: income - expense };
}
function budgetProgress(expense, budget) {
var e = Number(expense) || 0, b = Number(budget) || 0;
if (b <= 0) return { pct: 0, over: false, remain: 0, text: "未设置预算" };
var pct = Math.min(e / b * 100, 100); // 进度条封顶 100%
return {
pct: Math.round(pct * 10) / 10,
over: e > b,
remain: b - e,
text: e > b ? "已超预算 " + (e - b).toFixed(2) + " 元"
: "预算剩余 " + (b - e).toFixed(2) + " 元"
};
}js
/* 分类聚合:只统计支出,按金额降序,用于"这个月钱花哪儿了" */
function groupByCategory(records, month) {
var map = {};
(records || []).forEach(function (r) {
if (String(r.date || "").slice(0, 7) !== month || r.type !== "expense") return;
var k = r.cat || "其他";
map[k] = (map[k] || 0) + (Number(r.amount) || 0);
});
return Object.keys(map)
.map(function (k) { return { cat: k, amount: map[k] }; })
.sort(function (a, b) { return b.amount - a.amount; });
}代码示例:录入与本地存储
js
/* app.js —— 记一笔:校验 → 落盘 → 重渲染 */
function add() {
var amount = parseFloat($amount.value);
if (!(amount > 0)) { $amount.focus(); return; } // 金额必须为正
records.push({
date: $date.value || today(), // 未选日期则取今天
type: $type.value,
cat: $cat.value,
amount: amount,
note: $note.value.trim()
});
save(); // 写入 localStorage
$amount.value = ""; $note.value = "";
render(); // 汇总卡 + 表格 + 预算条
}
function save() {
try { localStorage.setItem(KEY, JSON.stringify(records)); } catch (e) {}
}完整可运行工程
可直接运行零第三方依赖无需改配置
运行环境:任意现代浏览器(Chrome / Edge / Firefox / Safari)+ 可选 Node.js ≥ 12 跑自测。
① 浏览器:下载全部文件放到同一目录,双击 index.html,点「示例数据」即可看到汇总、预算进度与表格;② 电脑端:node selftest.js(或 npm test),预期输出 ALL CHECKS PASSED,覆盖月度聚合、预算剩余/超支/封顶、分类降序、日期倒序与金额格式化。
ledger/
├── README.md 运行步骤、配置项与依赖清单
├── index.html 打开即用的页面(结构 + 引入脚本)
├── styles.css 样式(浅色 / 深色自动适配)
├── ledger-core.js 核心逻辑:月度聚合 / 分类聚合 / 预算进度(UMD)
├── app.js 页面交互:localStorage + 渲染
├── package.json 自测脚本入口(npm test)
└── selftest.js Node 最小用例:聚合 / 预算 / 排序 / 格式化
README.md
运行步骤、需先改的配置项与依赖清单(零依赖)。
markdown
# ledger · 个人记账与预算洞察(可运行工程)
本地优先的记账小工具:收支分类录入、月度统计、预算进度、分类排行。
数据只写入浏览器 `localStorage`(键名 `ncLedgerV1`),不联网、不上传。
## 目录结构
```
ledger/
├── index.html 打开即用的页面(结构 + 引入样式与脚本)
├── styles.css 页面样式(浅色 / 深色自动适配)
├── ledger-core.js 核心逻辑:月度聚合 / 分类聚合 / 预算进度(UMD,Node 可直接 require)
├── app.js 页面交互:localStorage 读写 + 表格与汇总渲染
├── selftest.js 电脑端最小用例(Node,零依赖)
├── package.json 自测脚本入口(npm test)
└── README.md 本文件
```
## 可直接运行
**① 浏览器(看真实界面)**
```bash
# 直接把 index.html 双击打开,或用任意静态服务器
python -m http.server 8000
# 打开 http://localhost:8000/index.html
```
点击「示例数据」→ 修改「月度预算」→ 观察汇总卡与进度条变化;记录刷新后仍在(localStorage)。
**② 电脑端最小用例(先验证逻辑,再动界面)**
```bash
node selftest.js # 或 npm test
```
预期输出最后一行 `ALL CHECKS PASSED`,覆盖:月度聚合只看当月、预算剩余 / 超支 / 进度封顶 /
未设置预算、分类聚合(仅支出 + 金额降序)、日期倒序排序且不改动原数组、金额两位小数格式化。
## 需先改的配置项
| 位置 | 配置项 | 说明 |
|---|---|---|
| `app.js` | `KEY = "ncLedgerV1"` | 本地存储键名,改掉可当作全新账本 |
| `ledger-core.js` | `CATS` | 收支分类字典,按自己的记账习惯增删 |
| `index.html` | `id="budget" value="3000"` | 默认月度预算(也可在页面上直接改) |
| `styles.css` | 颜色变量 | 演示用配色,接入自有设计体系时替换 |
## 依赖清单
- 浏览器端:**零依赖**(无 CDN、无第三方库,原生 DOM + localStorage);
- 电脑端自测:Node.js ≥ 12,**零 npm 依赖**(`package.json` 的 `dependencies` 为空)。
## 扩展方向
- 导出/导入 JSON 备份,或按月生成 CSV 报表;
- 增加「周期账单」(房租/订阅)自动入账;
- 用 `Intl.NumberFormat` 做千分位与货币符号本地化。
index.html
页面结构:录入表单 + 汇总卡 + 预算条 + 明细表,自包含无 CDN。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>个人记账与预算洞察</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="wrap">
<h1>个人记账与预算洞察</h1>
<p class="sub">数据保存在浏览器 localStorage,不联网、不上传。</p>
<div class="panel">
<label class="field">日期<input type="date" id="date" /></label>
<label class="field">类型
<select id="type">
<option value="expense">支出</option>
<option value="income">收入</option>
</select>
</label>
<label class="field">分类<select id="cat"></select></label>
<label class="field">金额<input type="number" id="amount" min="0" step="0.01" placeholder="0.00" /></label>
<label class="field">备注<input id="note" placeholder="可选" /></label>
<button class="primary" id="add">记一笔</button>
</div>
<div class="summary">
<div class="sum income">本月收入<b id="inc">0.00</b></div>
<div class="sum expense">本月支出<b id="exp">0.00</b></div>
<div class="sum balance">结余<b id="bal">0.00</b></div>
</div>
<div class="budget">
<label>月度预算 <input type="number" id="budget" value="3000" min="0" /> 元</label>
<span class="bar"><i id="bar"></i></span>
<span id="budgetTxt"></span>
</div>
<table>
<thead><tr><th>日期</th><th>类型</th><th>分类</th><th>金额</th><th>备注</th><th></th></tr></thead>
<tbody id="body"></tbody>
</table>
<div class="actions">
<button id="sample">示例数据</button>
<button id="clear">清空记录</button>
</div>
<p class="hint" id="status"></p>
</div>
<script src="ledger-core.js"></script>
<script src="app.js"></script>
</body>
</html>
styles.css
浅色 / 深色自适应样式,仅用系统字体与 CSS 变量。
css
/* styles.css —— 记账页样式,深浅两种配色自适应 */
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 28px 18px;
font-family: system-ui, "Segoe UI", "Microsoft YaHei", sans-serif;
background: #f6f7f8; color: #1a1d21;
}
.wrap { max-width: 900px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 6px; }
.sub, .hint { color: #5b636e; font-size: 0.86rem; }
.sub { margin: 0 0 20px; }
.hint { min-height: 1.3em; }
.panel { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.field { display: flex; flex-direction: column; gap: 4px; font-size: 0.78rem; color: #5b636e; }
input, select, button {
font: inherit; font-size: 0.88rem;
padding: 8px 11px; border: 1px solid #d6dae0; border-radius: 9px;
background: #fff; color: inherit;
}
input:focus, select:focus { outline: none; border-color: #1c6659; }
button { cursor: pointer; }
button.primary { background: #1c6659; border-color: #1c6659; color: #fff; }
.summary { display: flex; flex-wrap: wrap; gap: 12px; margin: 20px 0 14px; }
.sum { flex: 1 1 130px; border: 1px solid #e2e5ea; border-radius: 12px; padding: 12px; text-align: center; background: #fff; font-size: 0.78rem; color: #5b636e; }
.sum b { display: block; font-size: 1.25rem; margin-top: 4px; color: #1a1d21; }
.sum.income b { color: #2b7038; }
.sum.expense b { color: #b3352f; }
.sum.balance b { color: #2f5fbf; }
.budget { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; font-size: 0.84rem; color: #5b636e; }
.budget input { width: 90px; }
.bar { flex: 1 1 200px; height: 8px; border-radius: 999px; background: #e2e5ea; overflow: hidden; }
.bar i { display: block; height: 100%; width: 0; background: #2f5fbf; transition: width 0.2s ease; }
.bar i.over { background: #b3352f; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; font-size: 0.86rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #e2e5ea; }
th { font-weight: 600; color: #5b636e; }
td .del { cursor: pointer; opacity: 0.55; }
td .del:hover { opacity: 1; }
.actions { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
@media (prefers-color-scheme: dark) {
body { background: #14161a; color: #e6e9ee; }
.sub, .hint, th, .sum, .budget, .field { color: #9aa3b0; }
input, select, button, .sum { background: #1c1f25; color: #e6e9ee; border-color: #2a2f38; }
.sum b { color: #e6e9ee; }
.sum.income b { color: #6fb3a8; }
.sum.expense b { color: #e06c75; }
.sum.balance b { color: #7aa2f7; }
.bar { background: #2a2f38; }
.bar i { background: #7aa2f7; }
.bar i.over { background: #e06c75; }
th, td { border-color: #2a2f38; }
button.primary { background: #6fb3a8; border-color: #6fb3a8; color: #10130f; }
}
ledger-core.js
核心逻辑层(UMD):既能被浏览器引用,也能被 Node 直接 require 测试。
js
/* ledger-core.js —— 记账核心逻辑(UMD:浏览器 window.LedgerCore / Node require) */
(function (root, factory) {
if (typeof module === "object" && module.exports) module.exports = factory();
else root.LedgerCore = factory();
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
var CATS = {
expense: ["餐饮", "交通", "居住", "购物", "娱乐", "医疗", "其他"],
income: ["工资", "奖金", "理财", "兼职", "其他"]
};
function fmt(n) { return (Number(n) || 0).toFixed(2); }
function monthKey(dateStr) { return String(dateStr || "").slice(0, 7); }
function sortByDateDesc(records) {
return records.slice().sort(function (a, b) {
if (a.date === b.date) return 0;
return a.date < b.date ? 1 : -1;
});
}
/* 月度聚合:只统计当月收支 */
function summarize(records, month) {
var income = 0, expense = 0;
(records || []).forEach(function (r) {
if (monthKey(r.date) !== month) return;
if (r.type === "income") income += Number(r.amount) || 0;
else expense += Number(r.amount) || 0;
});
return { income: income, expense: expense, balance: income - expense };
}
/* 分类聚合(仅支出),按金额降序 */
function groupByCategory(records, month) {
var map = {};
(records || []).forEach(function (r) {
if (monthKey(r.date) !== month || r.type !== "expense") return;
var k = r.cat || "其他";
map[k] = (map[k] || 0) + (Number(r.amount) || 0);
});
return Object.keys(map).map(function (k) {
return { cat: k, amount: map[k] };
}).sort(function (a, b) { return b.amount - a.amount; });
}
/* 预算进度:进度条封顶 100%,超支单独提示 */
function budgetProgress(expense, budget) {
var e = Number(expense) || 0, b = Number(budget) || 0;
if (b <= 0) return { pct: 0, over: false, remain: 0, text: "未设置预算" };
var pct = Math.min(e / b * 100, 100);
return {
pct: Math.round(pct * 10) / 10,
over: e > b,
remain: b - e,
text: e > b ? "已超预算 " + fmt(e - b) + " 元" : "预算剩余 " + fmt(b - e) + " 元"
};
}
return {
CATS: CATS, fmt: fmt, monthKey: monthKey, sortByDateDesc: sortByDateDesc,
summarize: summarize, groupByCategory: groupByCategory, budgetProgress: budgetProgress
};
});
app.js
页面交互层:localStorage 读写、渲染表格与汇总、示例数据与清空。
js
/* app.js —— 页面交互:本地存储 + 渲染(依赖 ledger-core.js) */
(function () {
"use strict";
var C = window.LedgerCore;
var KEY = "ncLedgerV1";
var $ = function (id) { return document.getElementById(id); };
var $date = $("date"), $type = $("type"), $cat = $("cat"), $amount = $("amount"),
$note = $("note"), $body = $("body"), $budget = $("budget"), $bar = $("bar"),
$status = $("status");
var records = [];
try { records = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (e) { records = []; }
function today() {
var d = new Date();
return d.getFullYear() + "-" + ("0" + (d.getMonth() + 1)).slice(-2) + "-" + ("0" + d.getDate()).slice(-2);
}
function dstr(offsetDays) {
var x = new Date(Date.now() - offsetDays * 86400000);
return x.getFullYear() + "-" + ("0" + (x.getMonth() + 1)).slice(-2) + "-" + ("0" + x.getDate()).slice(-2);
}
function save() { try { localStorage.setItem(KEY, JSON.stringify(records)); } catch (e) {} }
function fillCats() {
$cat.innerHTML = "";
C.CATS[$type.value].forEach(function (c) {
var op = document.createElement("option");
op.value = op.textContent = c;
$cat.appendChild(op);
});
}
function render() {
var month = today().slice(0, 7);
var sum = C.summarize(records, month);
$("inc").textContent = C.fmt(sum.income);
$("exp").textContent = C.fmt(sum.expense);
$("bal").textContent = C.fmt(sum.balance);
var bp = C.budgetProgress(sum.expense, $budget.value);
$bar.style.width = bp.pct + "%";
$bar.className = bp.over ? "over" : "";
$("budgetTxt").textContent = bp.text;
$status.textContent = "共 " + records.length + " 笔记录;本月支出前三类:"
+ (C.groupByCategory(records, month).slice(0, 3).map(function (g) {
return g.cat + " " + C.fmt(g.amount);
}).join("、") || "暂无");
$body.innerHTML = "";
C.sortByDateDesc(records).forEach(function (r) {
var tr = document.createElement("tr");
["date", "type", "cat", "amount", "note"].forEach(function (k) {
var td = document.createElement("td");
td.textContent = k === "type" ? (r.type === "income" ? "收入" : "支出")
: k === "amount" ? C.fmt(r.amount) : (r[k] || "");
tr.appendChild(td);
});
var td = document.createElement("td");
var del = document.createElement("span");
del.className = "del";
del.textContent = "✕";
del.addEventListener("click", function () {
records.splice(records.indexOf(r), 1);
save(); render();
});
td.appendChild(del);
tr.appendChild(td);
$body.appendChild(tr);
});
}
function add() {
var amount = parseFloat($amount.value);
if (!(amount > 0)) { $amount.focus(); $status.textContent = "请先填写大于 0 的金额"; return; }
records.push({
date: $date.value || today(), type: $type.value,
cat: $cat.value, amount: amount, note: $note.value.trim()
});
save();
$amount.value = ""; $note.value = "";
render();
}
$type.addEventListener("change", fillCats);
$budget.addEventListener("change", render);
$("add").addEventListener("click", add);
$("clear").addEventListener("click", function () {
if (records.length && confirm("确认清空全部记账记录?")) { records = []; save(); render(); }
});
$("sample").addEventListener("click", function () {
records = [
{ date: dstr(1), type: "income", cat: "工资", amount: 12000, note: "本月工资" },
{ date: dstr(2), type: "expense", cat: "餐饮", amount: 46.5, note: "午餐" },
{ date: dstr(3), type: "expense", cat: "交通", amount: 12, note: "地铁" },
{ date: dstr(4), type: "expense", cat: "居住", amount: 2200, note: "房租" },
{ date: dstr(5), type: "expense", cat: "餐饮", amount: 33.5, note: "晚餐" }
];
save(); render();
});
$date.value = today();
fillCats();
render();
window.__ledgerReady = true;
})();
selftest.js
电脑端最小用例:5 组断言,跑通即逻辑正确。
js
/* selftest.js —— 零依赖最小用例:node selftest.js */
const assert = require("assert");
const C = require("./ledger-core.js");
const records = [
{ date: "2026-09-01", type: "income", cat: "工资", amount: 12000, note: "本月工资" },
{ date: "2026-09-02", type: "expense", cat: "餐饮", amount: 46.5, note: "午餐" },
{ date: "2026-09-03", type: "expense", cat: "交通", amount: 12, note: "地铁" },
{ date: "2026-09-04", type: "expense", cat: "餐饮", amount: 33.5, note: "晚餐" },
{ date: "2026-08-30", type: "expense", cat: "居住", amount: 2200, note: "上月房租" }
];
/* 1) 月度聚合:只统计当月,跨月记录不计入 */
const s = C.summarize(records, "2026-09");
assert.strictEqual(s.income, 12000, "本月收入");
assert.strictEqual(s.expense, 92, "本月支出");
assert.strictEqual(s.balance, 11908, "结余 = 收入 - 支出");
assert.strictEqual(C.summarize(records, "2026-07").expense, 0, "无记录月份为 0");
/* 2) 预算进度:剩余 / 超支 / 封顶 / 未设置 */
assert.deepStrictEqual(C.budgetProgress(92, 3000), { pct: 3.1, over: false, remain: 2908, text: "预算剩余 2908.00 元" });
const over = C.budgetProgress(3200, 3000);
assert.strictEqual(over.over, true);
assert.strictEqual(over.pct, 100, "进度条封顶 100%");
assert.strictEqual(over.text, "已超预算 200.00 元");
assert.strictEqual(C.budgetProgress(100, 0).text, "未设置预算");
/* 3) 分类聚合:仅支出,按金额降序 */
assert.deepStrictEqual(C.groupByCategory(records, "2026-09"),
[{ cat: "餐饮", amount: 80 }, { cat: "交通", amount: 12 }]);
/* 4) 排序:日期倒序,且不改动原数组 */
const sorted = C.sortByDateDesc(records);
assert.strictEqual(sorted[0].date, "2026-09-04");
assert.strictEqual(sorted[sorted.length - 1].date, "2026-08-30");
assert.strictEqual(records[0].date, "2026-09-01", "原数组未被修改");
/* 5) 金额格式化:两位小数,异常值兜底 */
assert.strictEqual(C.fmt(46.5), "46.50");
assert.strictEqual(C.fmt(undefined), "0.00");
console.log("ALL CHECKS PASSED");
package.json
依赖清单(dependencies 为空)与 npm test 入口。
json
{
"name": "ledger",
"version": "1.0.0",
"private": true,
"description": "个人记账与预算洞察(本地优先,零依赖)",
"scripts": {
"test": "node selftest.js"
},
"dependencies": {}
}
最小用例实跑输出:$ node selftest.js → ALL CHECKS PASSED(月度聚合 12 000 / 92 / 11908,预算超支与封顶,分类降序 餐饮 80 > 交通 12)。
来源参考
实现与部署所依据的官方文档 / 仓库(均可直接访问):