纯数据结构 + 比价逻辑的 JS 库,零依赖,可嵌入任意 SPA / 单页 HTML / PWA / Electron / WebView。
设计原则:无 UI、无框架依赖、数据优先、比价逻辑内建、ID 稳定、支持增量导入。
<!-- 引入核心库(必须) -->
<script src="expense-core.js"></script>
<!-- 引入存储层(可选,零配置 localStorage / zen-fs-config 双适配) -->
<script src="expense-store.js"></script>
<!-- 引入图表层(可选,纯 SVG 渲染) -->
<script src="expense-ui.js"></script>
<script>
// 创建一条花销记录
const record = ExpenseCore.createRecord({
date: "2026-07-31",
itemName: "纯牛奶",
brand: "特仑苏",
specification: "250ml*12",
merchant: "盒马",
unitPrice: 3.5,
quantity: 2,
categoryId: "cat_grocery"
});
// 比价:找最低价
const lowest = ExpenseCore.getLowestPrice(allRecords, "纯牛奶", "250ml*12");
// 按分类汇总
const summary = ExpenseCore.sumByCategory(allRecords);
</script>| 文件 | 引入方式 | 说明 |
|---|---|---|
expense-core.js |
必须 | 纯数据 + 逻辑(无 DOM,零依赖) |
expense-store.js |
可选 | 存储适配层(localStorage 默认 / zen-fs-config 可选) |
expense-ui.js |
可选 | 纯 SVG 图表渲染(饼图 / 柱状图 / 比价表) |
index.html |
示例 | 示范单页应用,展示所有 API 用法 |
test-expense-core.js |
Node | 核心库单元测试(Node 可直接运行) |
interface ExpenseRecord {
id: string; // UUID v4
date: string; // ISO 8601,如 "2026-07-31"
itemName: string; // 商品名称(比价聚合 Key)
categoryId: string; // 关联 Category.id
quantity: number; // 数量
unit: string; // 单位:个 / 瓶 / kg / 件 / 盒 ...
unitPrice: number; // 单价(不含税)
totalPrice: number; // 总价 = quantity × unitPrice
currency: string; // ISO 4217,默认 "CNY"
merchant: string; // 商家 / 渠道(比价核心字段)
brand?: string; // 品牌(增强比价)
specification?: string; // 规格:如 "250ml" "500g"(比价核心字段)
paymentMethod?: // 支付方式
| "cash" | "wechat" | "alipay"
| "credit_card" | "debit_card" | "other";
location?: string; // 消费地点(城市 / 商圈)
note?: string; // 备注
tags?: string[]; // 标签,如 ["囤货", "促销", "临期"]
receiptUrl?: string; // 小票图片 URL / base64
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}比价聚合 Key(推荐):
`${itemName}|${brand || ""}|${specification || ""}`
interface Category {
id: string; // 稳定 ID(见下方预置分类)
name: string; // 分类名称
icon: string; // Emoji 图标
color: string; // CSS 颜色值(用于图表)
parentId?: string; // 父分类 ID(支持二级分类)
monthlyBudget?: number; // 月度预算(可选)
}预置分类 ID(稳定,不建议修改):
| ID | 名称 | 图标 |
|---|---|---|
cat_food |
餐饮美食 | 🍜 |
cat_grocery |
日常买菜 | 🥬 |
cat_transport |
交通出行 | 🚗 |
cat_shopping |
购物消费 | 🛍️ |
cat_housing |
住房物业 | 🏠 |
cat_utilities |
水电燃气 | 💡 |
cat_health |
医疗健康 | 💊 |
cat_education |
教育培训 | 📚 |
cat_entertainment |
休闲娱乐 | 🎮 |
cat_travel |
旅行度假 | |
cat_subscription |
订阅服务 | 📱 |
cat_office |
办公用品 | 🖨️ |
cat_gift |
人情礼物 | 🎁 |
cat_other |
其他 | 📦 |
访问方式:ExpenseCore.PRESET_CATEGORIES
interface AppData {
version: "1.0";
records: ExpenseRecord[];
categories: Category[];
meta: {
lastExportAt: string; // ISO 8601
currency: string; // 默认货币,默认 "CNY"
};
}这是 JSON / CSV / Excel 导入导出的统一根结构。
| 名称 | 类型 | 说明 |
|---|---|---|
ExpenseCore.VERSION |
string |
库版本号,如 "1.0.0" |
ExpenseCore.PRESET_CATEGORIES |
Category[] |
14 个预置分类 |
ExpenseCore.DEFAULT_CURRENCY |
string |
默认货币 "CNY" |
创建空的 AppData 骨架,categories 已预置 14 个分类。
const data = ExpenseCore.createEmptyAppData();
// 或指定货币
const data = ExpenseCore.createEmptyAppData("USD");生成完整记录,自动补:id(UUID)、createdAt、updatedAt、totalPrice=qty×unitPrice、currency(默认 CNY)、unit(默认 "个")。
const r = ExpenseCore.createRecord({
date: "2026-07-31",
itemName: "纯牛奶",
categoryId: "cat_grocery",
unitPrice: 3.5,
quantity: 2,
merchant: "盒马",
brand: "特仑苏",
specification: "250ml*12"
});
// r.totalPrice === 7.0
// r.id 自动生成 UUID返回新对象,自动刷新 updatedAt。如果修改了 quantity 或 unitPrice 会重新计算 totalPrice。
const updated = ExpenseCore.updateRecord(r, { quantity: 3, merchant: "京东" });校验必填字段(id, date, itemName, categoryId, quantity, unitPrice, totalPrice, currency, merchant),返回错误信息数组。空数组表示校验通过。
const errors = ExpenseCore.validateRecord(record);
if (errors.length) console.error(errors);按 itemName|brand|specification 聚合所有购买记录。
const index = ExpenseCore.buildCompareIndex(data.records);
console.log(index["纯牛奶|特仑苏|250ml*12"]); // 该商品的所有购买记录找某商品的最低价记录(按 unitPrice 比较)。
const best = ExpenseCore.getLowestPrice(data.records, "纯牛奶", "250ml*12");
// best.unitPrice 是该商品历史最低单价按分类汇总金额,返回带分类名称/图标/颜色的结构化数据(UI 直接用)。
const summary = ExpenseCore.sumByCategory(data.records, data.categories);
// [{ id: "cat_food", name: "餐饮美食", icon: "🍜", color: "#ef4444", total: 1234.5 }, ...]按月汇总(默认当前年份)。
const monthly = ExpenseCore.sumByMonth(data.records, 2026);
// [{ month: "2026-01", total: 0 }, ..., { month: "2026-07", total: 850 }]按日期区间过滤(闭区间,ISO 8601 日期字符串)。
const jul = ExpenseCore.filterByDateRange(
data.records, "2026-07-01", "2026-07-31"
);获取指定分类下的全部记录。
将 AppData 序列化为格式化 JSON,自动更新 meta.lastExportAt。
- mode =
"replace":直接用新 JSON 覆盖(用于纯恢复场景) - mode =
"merge":按id合并 records/categories,以新数据updatedAt较新者为准
// 从备份恢复
const backup = JSON.stringify(data);
const restored = ExpenseCore.importJSON(backup, "replace");
// 增量导入(把另一份数据合并进来)
const merged = ExpenseCore.importJSON(anotherJson, "merge", currentData);date,itemName,categoryId,quantity,unit,unitPrice,totalPrice,currency,merchant,brand,specification,note,tags
2026-07-31,纯牛奶,cat_grocery,2,盒,3.5,7.0,CNY,盒马,特仑苏,250ml*12,促销,囤货|临期
categoryId用 ID,不用中文tags用|分隔- 空字段留空
CSV → 记录数组(核心字段)。自动为每条记录生成 id/createdAt/updatedAt。
支持中文表头(v1.1 新增):内置默认中文映射(日期/商品名/单价/商家/品牌/规格/...)。 支持自定义 fieldMap:用户传入的映射会与默认中文映射合并,可覆盖。
// 1. 直接用中文表头,零配置
const records = ExpenseCore.csvToRecords(`日期,商品名,单价,数量,商家
2026-07-31,纯牛奶,3.5,2,盒马`);
// 2. 自定义表头映射
const records = ExpenseCore.csvToRecords(csvText, {
fieldMap: { "购买日": "date", "东西": "itemName", "店家": "merchant" }
});
// 3. 严格模式:遇到第一行坏行就抛错(默认是静默跳过)
const records = ExpenseCore.csvToRecords(csvText, { skipInvalid: false });推荐用于 UI 导入场景:坏行不会中断,会收集到 errors 数组里返回。
const { records, errors } = ExpenseCore.csvToRecordsSafe(csvText);
// errors = [{ row: 5, reason: "必填字段缺失: itemName", raw: "..." }, ...]
if (errors.length) {
// 在 UI 上展示错误明细表给用户看
}记录数组 → CSV 文本。
生成 CSV 模板(表头 + 1 行示例),用于「下载模板」按钮。
const tpl = ExpenseCore.csvTemplate();
downloadBlob(tpl, "expense-template.csv", "text/csv");返回记录的业务去重键:date|itemName|brand|specification|merchant。同一天、同一商品、同一商家 → 视为重复。
const k = ExpenseCore.dedupeKeyOf(record);
// "2026-07-31|纯牛奶|特仑苏|250ml*12|盒马"在已有记录里查找重复。
opts.by |
含义 |
|---|---|
"dedupe"(默认) |
date + itemName + brand + specification + merchant 完全相同 |
"id" |
同 UUID |
"compareKey" |
同 itemName + brand + specification(忽略日期/商家,用于"同商品"判断) |
const dup = ExpenseCore.findDuplicate(newRecord, data.records, { by: "dedupe" });
if (dup) console.log("已存在相同记录");导入预演(dry-run):模拟合并,但不实际写入。返回分类后的明细,UI 直接渲染给用户确认。
const preview = ExpenseCore.previewImport(csvRecords, currentData, {
dedupeBy: "dedupe" // "dedupe" | "id" | "compareKey" | "none"
});
// preview 结构:
// {
// toAdd: [...], // 即将新增的记录
// toUpdate: [...], // 即将覆盖更新的记录(同 id 但 updatedAt 较新)
// duplicates: [...], // 重复被跳过的记录
// invalid: [{record, errors}], // 校验失败的记录
// summary: {
// total, add, update, duplicate, invalid,
// addAmount // 新增总金额
// }
// }
// UI 流程:
// 1) 渲染 preview.summary 给用户看
// 2) 用户点「确认导入」 → store.importRecords(preview.toAdd.concat(preview.toUpdate), "merge")dedupeBy 选项:
"dedupe"(默认): 按完整业务键去重(推荐)"compareKey": 只按商品比价键去重(同一商品不同日期也算重复)"id": 只按 UUID 去重(等价于原 merge 行为)"none": 不去重,全部视为新增
零配置启动,内置双后端:localStorage(默认,零依赖)和 zen-fs-config(启用后自动切换,支持 IndexedDB + 多端同步)。
<!-- 先引 expense-core.js,再引 expense-store.js -->
<script src="expense-core.js"></script>
<script src="expense-store.js"></script>
<script>
const store = await ExpenseStore.create({ appId: "my-expense-app" });
const data = await store.load(); // 从存储加载 AppData
await store.addRecord(record); // 追加记录并自动保存
await store.updateRecord(id, updates); // 更新记录
await store.deleteRecord(id); // 删除记录
await store.importRecords(records, "merge"); // 批量导入
await store.save(); // 手动触发保存
</script>| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
appId |
string |
必填 | 应用唯一标识,用于存储键隔离 |
backend |
"local" | "zen-fs-config" |
"local" |
存储后端选择 |
zenfsOptions |
object |
{} |
zen-fs-config 的额外配置(如 backendInfo 用于远程同步) |
启用 zen-fs-config(支持 IndexedDB + Gitee/GitHub 同步):
// 先通过 CDN 引入 zen-fs-config 相关依赖
const store = await ExpenseStore.create({
appId: "my-expense-app",
backend: "zen-fs-config",
zenfsOptions: {
// 可选:指定远程仓库作为同步后端
backendInfo: {
type: "gitee",
options: { owner: "your-name", repo: "expense-sync", token: "xxx" }
}
}
});| 方法 | 返回 | 说明 |
|---|---|---|
store.load() |
Promise<AppData> |
从存储加载数据,首次会创建空 AppData |
store.save() |
Promise<void> |
手动保存到存储(add/update/delete/import 会自动调用) |
store.getData() |
AppData |
获取内存中的当前数据(只读引用前请拷贝) |
store.addRecord(record) |
Promise<ExpenseRecord> |
追加记录并保存 |
store.updateRecord(id, updates) |
Promise<ExpenseRecord | null> |
按 ID 更新并保存 |
store.deleteRecord(id) |
Promise<boolean> |
按 ID 删除并保存 |
store.importRecords(newRecords, mode, opts?) |
Promise<AppData> |
批量导入,mode=merge/replace;opts.dedupeBy="dedupe" 可开启业务键去重 |
store.replaceData(newData) |
Promise<void> |
整体替换 AppData 并保存 |
const store = await ExpenseStore.create({ appId: "my-app" });
await store.load();
// 1. 解析 CSV(推荐 Safe 版,能拿到错误明细)
const { records, errors } = ExpenseCore.csvToRecordsSafe(csvText);
if (errors.length) { /* 展示给用户看 */ }
// 2. 预演:看看会发生什么
const preview = ExpenseCore.previewImport(records, store.getData(), {
dedupeBy: "dedupe"
});
console.log(`将新增 ${preview.summary.add} 条 / 更新 ${preview.summary.update} 条 / 跳过 ${preview.summary.duplicate} 条重复 / 非法 ${preview.summary.invalid} 条`);
// 3. 用户确认后再真正写入
const effective = preview.toAdd.concat(preview.toUpdate);
await store.importRecords(effective, "merge");
// 或者直接用带去重的导入(一步到位,不需要 preview)
// await store.importRecords(records, "merge", { dedupeBy: "dedupe" });纯 SVG 绘制,零外部库。所有渲染函数接受一个选择器字符串(挂载点)。
渲染分类饼图,带图例和百分比标签。
const summary = ExpenseCore.sumByCategory(data.records, data.categories);
ExpenseUI.renderPieChart("#pie-chart", summary, {
width: 400, // 默认 400
height: 300, // 默认 300
title: "分类占比" // 可选标题
});渲染月度趋势柱状图,带数值标注。
const monthly = ExpenseCore.sumByMonth(data.records, 2026);
ExpenseUI.renderBarChart("#bar-chart", monthly, {
width: 600,
height: 240,
title: "2026 月度支出",
barColor: "#6366f1"
});渲染比价对比表,最低价记录整行绿色高亮,展示差价百分比。
const records = ExpenseCore.buildCompareIndex(data.records)["纯牛奶|特仑苏|250ml*12"] || [];
ExpenseUI.renderCompareTable("#compare-table", records, {
unitLabel: "元/盒"
});// React: 自定义 Hook
import { useEffect, useState } from "react";
import ExpenseCore from "./expense-core.js";
export function useExpenseData() {
const [data, setData] = useState(() => ExpenseCore.createEmptyAppData());
useEffect(() => {
const saved = localStorage.getItem("expense-data");
if (saved) setData(ExpenseCore.importJSON(saved, "replace"));
}, []);
const addRecord = (partial) => {
const r = ExpenseCore.createRecord(partial);
const next = { ...data, records: [...data.records, r] };
localStorage.setItem("expense-data", ExpenseCore.exportJSON(next));
setData(next);
};
return { data, addRecord };
}const db = await idb.openDB("expense", 1, {
upgrade(db) { db.createObjectStore("records", { keyPath: "id" }); }
});
// 写入:db.put("records", record)
// 读出:db.getAll("records") 然后用 ExpenseCore 做汇总const myCategory = {
id: "cat_baby",
name: "母婴用品",
icon: "🍼",
color: "#ec4899"
};
const data = ExpenseCore.createEmptyAppData();
data.categories.push(myCategory);# 使用 Node 直接运行测试(无额外依赖)
node test-expense-core.js测试覆盖:构造函数、校验、比价索引、分类汇总、JSON 导入导出(merge/replace)、CSV 双向转换、中文表头映射、错误收集、CSV 模板、去重、导入预演等 52 项核心场景。
MIT