纯前端单文件记账统计源码|收支流水 + 图表 + CSV 导出(解压即用)

前言

想随手记两笔收支,又不想装 App?今天分享一套纯前端单文件记账源码:收支流水、分类备注、月度统计、近 7 日柱状图、CSV 导出,双击 HTML 就能用。

声明:本文源码为原创教学示例,请勿用于违法违规用途;请遵守当地法律法规与目标服务条款。

功能特性

  • 收入 / 支出两类,9 种常用分类
  • 本月收入、支出、结余实时统计
  • 近 7 日支出柱状图(纯 CSS 绘制)
  • 账单列表、删除、CSV 导出
  • localStorage 本地保存,数据不出浏览器
  • 深色 UI,手机可用,单文件可分享

环境要求

  • 任意现代浏览器(Chrome / Edge / Firefox / Safari)
  • 无需服务器、数据库与构建工具

目录结构

lite-account/
├── index.html    # 全部逻辑在这一个文件里
└── README.md

完整源码

index.html

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>轻记账</title>
  <style>
    :root { --bg:#0f1115; --panel:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b93a7; --accent:#f04494; --ok:#3dd68c; --warn:#ffb020; }
    * { box-sizing:border-box; }
    body { margin:0; font-family:system-ui,-apple-system,"PingFang SC",sans-serif; background:var(--bg); color:var(--text); }
    .wrap { max-width:980px; margin:0 auto; padding:28px 16px 50px; }
    header { display:flex; align-items:center; gap:12px; margin-bottom:20px; }
    .logo { width:42px; height:42px; border-radius:12px; background:linear-gradient(135deg,var(--accent),#ff8f3a); display:grid; place-items:center; font-weight:800; color:#fff; }
    h1 { margin:0; font-size:24px; }
    .sub { color:var(--muted); font-size:13px; }
    .grid { display:grid; grid-template-columns:1fr 1.2fr; gap:16px; }
    @media (max-width:860px){ .grid{ grid-template-columns:1fr; } }
    .card { background:var(--panel); border:1px solid var(--border); border-radius:18px; padding:18px; }
    h3 { margin:0 0 12px; font-size:16px; }
    label { display:block; font-size:13px; color:var(--muted); margin:10px 0 6px; }
    input, select { width:100%; padding:11px 12px; border-radius:10px; border:1px solid var(--border); background:var(--bg); color:var(--text); font:inherit; outline:0; }
    input:focus, select:focus { border-color:var(--accent); }
    button, .btn { border:0; border-radius:999px; padding:11px 16px; font:inherit; font-weight:600; cursor:pointer; background:var(--accent); color:#fff; }
    .btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
    .row { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
    .stats { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin-bottom:14px; }
    .stat { background:var(--bg); border:1px solid var(--border); border-radius:12px; padding:12px; text-align:center; }
    .stat b { display:block; font-size:22px; color:var(--accent); }
    .stat span { color:var(--muted); font-size:12px; }
    .chart { height:180px; display:flex; align-items:flex-end; gap:8px; padding:10px 4px 0; }
    .bar { flex:1; background:linear-gradient(180deg,var(--accent),#ff8f3a); border-radius:8px 8px 4px 4px; min-height:4px; position:relative; }
    .bar i { position:absolute; left:0; right:0; top:-18px; text-align:center; font-size:10px; color:var(--muted); font-style:normal; }
    .bar u { position:absolute; left:0; right:0; bottom:-18px; text-align:center; font-size:10px; color:var(--muted); text-decoration:none; }
    table { width:100%; border-collapse:collapse; font-size:14px; }
    th, td { padding:10px 8px; border-top:1px solid var(--border); text-align:left; }
    th { color:var(--muted); font-weight:500; font-size:12px; }
    .tag { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; background:rgba(240,68,148,.12); color:var(--accent); }
    .income { color:var(--ok); }
    .expense { color:#ff8f8f; }
    .del { background:transparent; border:1px solid var(--border); color:var(--muted); border-radius:8px; padding:4px 8px; cursor:pointer; }
    .toast { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); background:rgba(240,68,148,.95); color:#fff; padding:10px 16px; border-radius:999px; opacity:0; transition:.25s; pointer-events:none; }
    .toast.show { opacity:1; }
  </style>
</head>
<body>
<div class="wrap">
  <header>
    <div class="logo">$</div>
    <div>
      <h1>轻记账</h1>
      <div class="sub">收支记录 · 分类统计 · 本地保存 · 单文件</div>
    </div>
    <div style="flex:1"></div>
    <button class="btn-ghost" id="export">导出 CSV</button>
    <button class="btn-ghost" id="clear">清空数据</button>
  </header>

  <div class="stats">
    <div class="stat"><b id="sumIn">0</b><span>本月收入</span></div>
    <div class="stat"><b id="sumOut">0</b><span>本月支出</span></div>
    <div class="stat"><b id="sumBal">0</b><span>本月结余</span></div>
  </div>

  <div class="grid">
    <section class="card">
      <h3>记一笔</h3>
      <label>类型</label>
      <select id="type">
        <option value="expense">支出</option>
        <option value="income">收入</option>
      </select>
      <label>分类</label>
      <select id="cat">
        <option>餐饮</option>
        <option>交通</option>
        <option>购物</option>
        <option>居住</option>
        <option>娱乐</option>
        <option>医疗</option>
        <option>工资</option>
        <option>红包</option>
        <option>其他</option>
      </select>
      <label>金额(元)</label>
      <input id="amount" type="number" min="0.01" step="0.01" placeholder="0.00">
      <label>备注</label>
      <input id="note" maxlength="40" placeholder="可选">
      <div class="row">
        <button id="add">添加记录</button>
      </div>

      <h3 style="margin-top:20px">近 7 日支出</h3>
      <div class="chart" id="chart"></div>
    </section>

    <section class="card">
      <h3>最近账单</h3>
      <table>
        <thead>
          <tr><th>时间</th><th>分类</th><th>备注</th><th>金额</th><th></th></tr>
        </thead>
        <tbody id="list"></tbody>
      </table>
    </section>
  </div>
</div>
<div class="toast" id="toast"></div>

<script>
(function () {
  const $ = (id) => document.getElementById(id);
  const LS = {
    get(k, d) {
      try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch (e) { return d; }
    },
    set(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
  };
  let items = LS.get('lite-account.items', []);

  function toast(m) {
    const el = $('toast');
    el.textContent = m;
    el.classList.add('show');
    clearTimeout(el._t);
    el._t = setTimeout(() => el.classList.remove('show'), 1600);
  }

  function fmt(n) {
    return (Math.round(n * 100) / 100).toLocaleString('zh-CN');
  }

  function monthKey(d) {
    return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
  }

  function dayKey(d) {
    return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
  }

  function renderStats() {
    const mk = monthKey(new Date());
    let inn = 0, out = 0;
    items.forEach((it) => {
      if (monthKey(new Date(it.t)) !== mk) return;
      if (it.type === 'income') inn += it.amount;
      else out += it.amount;
    });
    $('sumIn').textContent = fmt(inn);
    $('sumOut').textContent = fmt(out);
    $('sumBal').textContent = fmt(inn - out);
  }

  function renderChart() {
    const box = $('chart');
    box.innerHTML = '';
    const days = [];
    for (let i = 6; i >= 0; i--) {
      const d = new Date();
      d.setDate(d.getDate() - i);
      const key = dayKey(d);
      const sum = items
        .filter((it) => it.type === 'expense' && dayKey(new Date(it.t)) === key)
        .reduce((s, it) => s + it.amount, 0);
      days.push({ key, sum, label: (d.getMonth() + 1) + '/' + d.getDate() });
    }
    const max = Math.max(1, ...days.map((x) => x.sum));
    days.forEach((d) => {
      const div = document.createElement('div');
      div.className = 'bar';
      const h = Math.max(6, Math.round((d.sum / max) * 150));
      div.style.height = h + 'px';
      div.innerHTML = '<i>' + (d.sum ? fmt(d.sum) : '') + '</i><u>' + d.label + '</u>';
      box.appendChild(div);
    });
  }

  function renderList() {
    const tb = $('list');
    tb.innerHTML = '';
    const rows = items.slice().sort((a, b) => b.t - a.t).slice(0, 30);
    rows.forEach((it) => {
      const tr = document.createElement('tr');
      const d = new Date(it.t);
      tr.innerHTML =
        '<td>' + (d.getMonth() + 1) + '/' + d.getDate() + '</td>' +
        '<td><span class="tag"></span></td>' +
        '<td class="note"></td>' +
        '<td class="' + (it.type === 'income' ? 'income' : 'expense') + '"></td>' +
        '<td><button class="del">删除</button></td>';
      tr.querySelector('.tag').textContent = it.cat;
      tr.querySelector('.note').textContent = it.note || '—';
      tr.querySelector('.expense, .income').textContent =
        (it.type === 'income' ? '+' : '-') + fmt(it.amount);
      tr.querySelector('.del').onclick = () => {
        items = items.filter((x) => x.id !== it.id);
        LS.set('lite-account.items', items);
        renderAll();
        toast('已删除');
      };
      tb.appendChild(tr);
    });
    if (!rows.length) {
      tb.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;padding:24px">暂无记录</td></tr>';
    }
  }

  function renderAll() {
    renderStats();
    renderChart();
    renderList();
  }

  $('add').onclick = () => {
    const amount = parseFloat($('amount').value);
    if (!amount || amount <= 0) {
      toast('请输入金额');
      return;
    }
    items.push({
      id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
      t: Date.now(),
      type: $('type').value,
      cat: $('cat').value,
      amount,
      note: $('note').value.trim()
    });
    LS.set('lite-account.items', items);
    $('amount').value = '';
    $('note').value = '';
    renderAll();
    toast('已记一笔');
  };

  $('export').onclick = () => {
    const head = 'time,type,category,amount,note\n';
    const body = items.map((it) => {
      return [
        new Date(it.t).toLocaleString('zh-CN'),
        it.type,
        it.cat,
        it.amount,
        '"' + (it.note || '').replace(/"/g, '""') + '"'
      ].join(',');
    }).join('\n');
    const blob = new Blob(['' + head + body], { type: 'text/csv;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'account-' + dayKey(new Date()) + '.csv';
    a.click();
    toast('已导出 CSV');
  };

  $('clear').onclick = () => {
    if (!confirm('确认清空全部账单?')) return;
    items = [];
    LS.set('lite-account.items', items);
    renderAll();
    toast('已清空');
  };

  renderAll();
})();
</script>
</body>
</html>

📦 源码下载

下载 lite-account.zip(完整可用源码)

下载解压后按文中「部署步骤」即可运行。若正文代码与压缩包不一致,以压缩包为准。

部署步骤

  1. 下载 zip 解压,双击 index.html 即可开始记账。
  2. 也可上传到静态空间 / 对象存储,做成自己的小工具页。
  3. 可按需修改分类列表(搜索 <select id="cat">)。

安全与合规说明

  • 数据仅存本机 localStorage,请勿在公共电脑记录敏感财务信息。
  • 清理浏览器数据会清空账单,重要数据请用「导出 CSV」备份。
  • 本源码仅供学习交流,请勿用于违法违规用途。

小结

单文件即可完成「记账 → 统计 → 导出」闭环。下一步可扩展:预算提醒、多账本、饼图、云同步。需要增强版可在评论区留言。

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容