个人记账与预算洞察

源自想法 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)。

复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考