前言
想随手记两笔收支,又不想装 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>
部署步骤
- 下载 zip 解压,双击
index.html即可开始记账。 - 也可上传到静态空间 / 对象存储,做成自己的小工具页。
- 可按需修改分类列表(搜索
<select id="cat">)。
安全与合规说明
- 数据仅存本机 localStorage,请勿在公共电脑记录敏感财务信息。
- 清理浏览器数据会清空账单,重要数据请用「导出 CSV」备份。
- 本源码仅供学习交流,请勿用于违法违规用途。
小结
单文件即可完成「记账 → 统计 → 导出」闭环。下一步可扩展:预算提醒、多账本、饼图、云同步。需要增强版可在评论区留言。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END






暂无评论内容