慧记账 SmartLedger:从 0 到 1 打造一款「能上线」的记账应用
一篇关于记账应用 慧记账 SmartLedger 的设计原理、架构连接、功能拆解与安全实现的工程博客。
它不是一份需求文档,而是把一个真实跑在 Vercel 上、打包成安卓 APK、并已生成 iOS 工程的应用,从数据模型讲到部署分发的完整复盘。
一、为什么做这个
市面上的记账软件很多——随手记、鲨鱼记账、一木记账、钱迹……它们各有所长,但也各有痛点:广告多、核心功能藏在付费墙后、数据封闭、隐私不透明。
我们想做一款「功能堆满、但每一处都真能用」的记账应用,定位是:美观权威、功能越全越好、隐私安全、能上线分发。于是有了慧记账 SmartLedger。

二、技术栈一览
| 层次 | 选型 | 用途 |
|---|---|---|
| 框架 | React 19 + TypeScript 5.9 | 应用主体、类型安全 |
| 构建 | Vite 8 | 开发与打包 |
| 样式 | Tailwind CSS 4 + shadcn/ui(55 组件) | 组件化、主题化 |
| 路由 | react-router-dom 7 | 页面导航与路由守卫 |
| 状态 | React Context + useReducer 风格不可变更新 | 全局账本状态 |
| 图表 | recharts / echarts | 报表、热力图、趋势图 |
| 日期 | date-fns | 周期计算、顺延、月份序列 |
| 动效 | framer-motion / gsap | 页面与交互动画 |
| 通知 | sonner | Toast 提示 |
| 表单 | react-hook-form + zod | 表单校验 |
| 原生封装 | Capacitor 8(android / ios) | 打包安卓 APK、生成 iOS 工程 |
| 部署 | Vercel(SPA)/ 妙搭托管 | 线上访问 |
关键点:纯前端 + 浏览器本地存储(localStorage),无服务端运行时。因此整条数据链路、登录、安全都在浏览器内完成,能直接跑、能上线,代价是数据不跨设备自动同步(见文末边界说明)。
三、架构与连接:模块是怎么串起来的
3.1 Provider 嵌套(顶层连接关系)
应用入口 app.tsx 用一组 Provider 把各能力「插」成一颗树,顺序有讲究:
<AuthProvider> // ① 登录态:决定你是谁
<SecurityProvider> // ② PIN 应用锁 + 隐私模式
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}> // ③ 路由守卫:未登录跳回登录页
<LedgerProvider> // ④ 账本数据:按账号加载/持久化
<ReminderProvider> // ⑤ 记账提醒轮询
<AddTxProvider> // ⑥ 全局「记一笔」弹窗
<Layout /> // 侧栏 + 页面出口
</AddTxProvider>
</ReminderProvider>
</LedgerProvider>
</Route>
</Routes>
</SecurityProvider>
</AuthProvider>
连接逻辑:
- 认证在最外层——任何模块都依赖「当前登录的是谁」,
LedgerProvider用useAuth()拿到user.username来决定数据存哪把锁。 - 安全锁在路由之前——应用锁、隐私模式要挡在页面渲染前。
- 数据层在登录之后——
RequireAuth保证只有登录用户才进入账本相关 Provider。 - 共享状态通过 Context 单向流动:
AuthContext→LedgerContext→ 各页面。
3.2 数据流(一次记一笔,发生了什么)
用户在「记一笔」弹窗填写
↓
AddTxProvider 调用 ledger.addTransaction(tx)
↓
LedgerProvider.setState(不可变更新 activeLedger 的 transactions 数组)
↓
useEffect 监听 state 变化 → saveState(写入 localStorage)
↓
所有 useLedger() 的页面重新渲染(首页结余、报表、账户余额联动刷新)
核心:一份全局状态,驱动全部页面。增删改任何一笔,首页结余、账户余额、预算进度、报表、热力图全部同步重算,因为都从同一个 state 派生。
四、数据模型:记账应用的心脏
4.1 实体类型
| 实体 | 字段要点 | 作用 |
|---|---|---|
ITransaction |
type(支出/收入/转账)、amount、categoryId、accountId、date、note、tags、reimbursable/reimbursed | 每一笔流水 |
IAccount |
name、type(现金/储蓄卡/信用卡/支付宝/微信/投资理财/虚拟)、baseBalance、color | 账户与初始余额 |
ICategory |
name、type、color、icon | 收支分类 |
IBudget |
categoryId(null=总预算)、month、limit | 月度预算 |
IDebt |
person、type(lend=借出/borrow=欠款)、amount、settled | 债务 |
ISubscription |
name、amount、cycle(月/年)、nextDate、autoBook | 周期性订阅 |
4.2 状态顶层结构
interface ILedgerState {
ledgers: Record<string, ILedgerData>; // 多账本
activeLedgerId: string;
ledgerMeta: ILedgerMeta[];
categories: ICategory[];
profile: IProfile; // 主题/币种/会员/隐私/提醒
debts: IDebt[];
subscriptions: ISubscription[];
}
设计要点:
- 多账本用
Record<string, ILedgerData>表达,每个账本内部自含transactions/accounts/budgets,互不干扰;activeLedgerId决定当前操作哪个账本。 ILedgerData(账务)+debts/subscriptions(跨账本)分层,既满足「每本账独立」,又支持「债务/订阅全局可见」。
4.3 种子与空账本
buildSeedState():返回 17 笔演示流水、5 个账户、3 个预算、2 笔债务、2 个订阅,用于演示。buildEmptyState():全零空账本,内置授权账号jasper首次登录走这条——从零开始记账,无任何演示数据干扰。migrateState(parsed):版本迁移器。旧版本是「单账本」扁平结构,新版是多账本。加载时若发现旧结构,自动包装成默认账本并保留原流水/账户/预算,保证老用户数据不丢。
五、持久化:数据存在哪、怎么隔离
5.1 localStorage 命名空间
| Key | 内容 |
|---|---|
ledger-app:auth |
登录态、失败计数与锁定时间 |
ledger-app:security |
PIN 锁设置 |
ledger-app:state:<username> |
按账号隔离的账本数据(如 ledger-app:state:jasper) |
5.2 加载策略(关键:账号隔离 + 兼容迁移)
function loadState(key) {
const tryLoad = (k) => { /* 读 + JSON.parse + migrateState */ };
if (key === 'ledger-app:state:jasper') return tryLoad(key) ?? buildEmptyState();
return tryLoad(key) ?? tryLoad('ledger-app:state') ?? buildSeedState();
}
- 每个账号的数据锁在自己的 key 里,不同账号互不可见。
- 内置授权账号从零开始(不回退旧全局演示数据)。
- 每次任何状态变更,
useEffect自动写回 localStorage —— 改了就存,刷新不丢。
边界:这些数据只在当前这台设备、这个浏览器里。换浏览器/换设备就是独立存储;跨设备自动同步需要云后端(见文末)。
六、统计原理:所有数字怎么算出来的
统计全部收敛在 stats.ts 的纯函数里(不碰 UI、不写状态,可测试):
monthBalance(txns, key):本月结余 = 收入 − 支出(按月 key 过滤)。monthSeries(txns, keys):最近 N 个月的收入/支出/结余序列 → 喂给折线图。categoryBreakdown(...):分类汇总 → 饼图/排行。accountBalance(account, txns):账户余额 = 初始余额 + 收入 − 支出 ± 转账(转出减、转入加)。totalAsset(accounts, txns):总资产 = 所有账户余额之和。budgetSpent(...):预算已花 = 该分类当月支出(总预算则不计分类)。
「名下净资产」卡片 = 总资产 + 借出 − 欠款,由
totalAsset与债务联动得出——改动任一笔,净资产实时重算。
七、安全设计:密码、PIN、隐私、防爆破
用户要求「安全」,这里做得比较扎实:
7.1 密码:PBKDF2-SHA256 + 随机盐,明文不落盘
const ITERATIONS = 100000; // 10 万次迭代
async function derive(password, salt) {
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', ...);
return crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' }, key, 256);
}
- 存储的是
salt + hash(16 字节随机盐 + 32 字节散列),任何地方都不存明文密码。 - 内置授权账号
jasper的散列由pbkdf2Sync(100000, SHA-256, 32B)预置在代码里,登录时用同一算法verifyPassword比对。
7.2 单授权账号模式
register已禁用(返回「本应用为授权账号专属,不支持自助注册」)。- 登录只认
jasper,其他用户名统一返回「账号不存在或无权登录」——不泄露账号是否存在,避免被探测。 - 路由
/register与注册页已删除,登录页标注「仅预置账号可登录」。
7.3 防爆破锁定
连续失败 5 次,锁定 30 秒(count >= 5 → lockUntil = now + 30_000),期间即使密码正确也拒绝。
7.4 PIN 应用锁 + 隐私模式
- PIN 应用锁:为应用再加一道锁(独立于密码)。
- 隐私模式:开启后全站金额显示为
•••,防旁人偷看。
八、功能清单(对标竞品,越全越好)
| 模块 | 功能 |
|---|---|
| 首页 | 本月结余、收支、预算进度、总资产、最近账单 |
| 账单 | 增删改、支出/收入/转账、报销标记、分类、标签 |
| 账户 | 多类型账户、初始余额、名下净资产、资产构成占比 |
| 多账本 | 多本独立账本、切换、增删 |
| 预算 | 月度总预算 + 分类预算、超支预警 |
| 报表 | 月度折线/饼图、日历热力(月)、年度热力矩阵(365 天 GitHub 风格)、同比洞察、资产趋势 |
| 年报 | 全年收支/结余率/记账天数/12 月柱状图/年度排行/年度关键词 |
| 债务 | 借出/欠款/结清,联动净资产 |
| 订阅 | 月/年周期、7 天待扣提醒、到期自动记一笔并顺延、已续费 |
| 会员 | 免费版 / 专业版分层权益 |
| 安全中心 | PIN 锁、自动锁定、隐私模式、完整 JSON 备份/恢复、CSV 导出(专业版) |
| 设置 | 主题(深浅)、币种、跨设备同步(导出/导入)、清空账本数据、记账提醒、数据存储说明 |
8.1 亮点机制:周期订阅「自动入账」
LedgerProvider 挂载时检查所有开启 autoBook 的订阅:
if (due <= now) {
newTxns.unshift(支出账单);
nextDate = cycle === 'monthly' ? addMonths(due, 1) : addYears(due, 1);
}
到期自动生成一笔支出并顺延下次扣款日期,无需手动——这是「一木记账/钱迹」一类竞品的核心体验。
九、分发与上线:网页、APK、iOS 是怎么连起来的
9.1 双部署链路(同一份代码)
- Vercel:
vite.config.ts用isVercel=!!process.env.VERCEL切分支,VERCEL=1时产出标准 SPA(dist/),vercel.json做 SPA 回退(除静态资源外全部 rewrite 到index.html)。稳定线上:https://ledger-app-lilac-kappa.vercel.app。 - 妙搭托管:走
miaodaOutputPlugin,产出dist/output(同源资源)+dist/output_resource(CDN 资源),另生成routes.json。
9.2 Capacitor 打包原生应用
npm i @capacitor/core @capacitor/cli @capacitor/android @capacitor/ios
capacitor.config.ts: { appId:'com.smartledger.app', appName:'慧记账 SmartLedger', webDir:'dist', server:{ androidScheme:'https' } }
VERCEL=1 npm run build → 产出标准 SPA 到 dist
npx cap add android / ios → 生成原生工程
npx cap sync → 把 web 产物拷入原生 assets
./gradlew assembleDebug → 产出 app-debug.apk
- 安卓:已产出
app-debug.apk(约 4.3MB,debug 签名可直接侧载安装,Android 7.0+),web 界面完整打入。 - iOS:已生成
ios/App/App.xcodeproj(Xcode 工程,包名com.smartledger.app)。真机安装 .ipa 需要 Apple 开发者账号签名(年费约 ¥688),工程就绪,只差签名一步。
9.3 PWA:无需安装包即可「添加主屏幕」
public/manifest.webmanifest+service worker+ 图标,PROD下注册(Capacitor 原生壳内自动跳过)。- 安卓 Chrome / iPhone Safari「添加到主屏幕」即生成全屏应用图标。
十、诚实的边界与下一步
当前已知边界(如实说明):
- 数据存在浏览器/设备本地,换设备不自动互通。
- 会员、PIN、到期自动记账均为本地逻辑,无真实支付/推送。
- iOS 真机安装需付费开发者账号签名。
下一步(可做):
- 接入云端后端(如 Vercel KV / Supabase)实现跨设备自动云同步,按 jasper 账号隔离——安卓记的账,苹果也能完整看到。
- 拿到 Apple 开发者账号后,用现有 iOS 工程签名发布到 App Store / TestFlight。
写于 2026-10 · 慧记账 SmartLedger 工程:/Users/apple/code/doubao_jizhang/ledger-app
评论区