Files
ledger/index.js

353 lines
11 KiB
JavaScript

import { readFile, writeFile } from "node:fs/promises";
import http from "node:http";
import { URL } from "node:url";
import path from "node:path";
import { existsSync } from "node:fs";
const [, , cmd, ...args] = process.argv;
const DB_FILE = "./ledger.json";
async function load() {
try {
const text = await readFile(DB_FILE, "utf-8");
return JSON.parse(text || "[]");
} catch {
return [];
}
}
async function save(data) {
await writeFile(DB_FILE, JSON.stringify(data, null, 2));
}
function pickDateArg(argv) {
const dateFlagIdx = argv.indexOf("--date");
if (dateFlagIdx === -1) return null;
return argv[dateFlagIdx + 1] || null;
}
function isValidDateString(dateText) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateText)) return false;
const [y, m, d] = dateText.split("-").map(Number);
const dt = new Date(y, m - 1, d);
return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d;
}
function toDayText(isoTime) {
const d = new Date(isoTime);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function printHelp() {
console.log("用法:");
console.log(" node index.js add <金额> <备注>");
console.log(" node index.js list [--date YYYY-MM-DD]");
console.log(" node index.js sum [--date YYYY-MM-DD]");
console.log(" node index.js remove <id>");
console.log(" node index.js serve [port]");
}
function withDateFilter(data, dateText) {
if (!dateText) return data;
return data.filter((x) => toDayText(x.createdAt) === dateText);
}
function applyFilters(data, url) {
const dateText = url.searchParams.get("date");
const q = (url.searchParams.get("q") || "").trim().toLowerCase();
const type = url.searchParams.get("type");
const category = (url.searchParams.get("category") || "").trim();
let selected = dateText ? withDateFilter(data, dateText) : data;
if (type === "income") selected = selected.filter((x) => x.amount >= 0);
if (type === "expense") selected = selected.filter((x) => x.amount < 0);
if (category) selected = selected.filter((x) => x.category === category);
if (q) selected = selected.filter((x) => x.note.toLowerCase().includes(q));
return selected;
}
async function readJsonBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf-8");
return raw ? JSON.parse(raw) : {};
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end(JSON.stringify(payload));
}
function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".html") return "text/html; charset=utf-8";
if (ext === ".js") return "application/javascript; charset=utf-8";
if (ext === ".css") return "text/css; charset=utf-8";
if (ext === ".json") return "application/json; charset=utf-8";
if (ext === ".svg") return "image/svg+xml";
if (ext === ".png") return "image/png";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".ico") return "image/x-icon";
return "application/octet-stream";
}
async function serveStaticFile(res, pathname) {
const distDir = path.resolve("./web/dist");
const safePath = pathname === "/" ? "/index.html" : pathname;
const filePath = path.resolve(path.join(distDir, `.${safePath}`));
if (!filePath.startsWith(distDir)) {
sendJson(res, 403, { error: "forbidden" });
return true;
}
const targetPath = existsSync(filePath) ? filePath : path.join(distDir, "index.html");
if (!existsSync(targetPath)) {
sendJson(res, 404, { error: "前端资源不存在,请先执行: cd web && npm run build" });
return true;
}
const content = await readFile(targetPath);
res.writeHead(200, { "Content-Type": getContentType(targetPath) });
res.end(content);
return true;
}
function startApiServer(port = 3001) {
const server = http.createServer(async (req, res) => {
if (!req.url || !req.method) {
sendJson(res, 400, { error: "bad request" });
return;
}
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
const url = new URL(req.url, `http://localhost:${port}`);
const pathname = url.pathname;
try {
if (req.method === "GET" && pathname === "/api/entries") {
const dateText = url.searchParams.get("date");
if (dateText && !isValidDateString(dateText)) {
sendJson(res, 400, { error: "日期格式错误,请使用 YYYY-MM-DD" });
return;
}
const data = await load();
sendJson(res, 200, { data: applyFilters(data, url) });
return;
}
if (req.method === "GET" && pathname === "/api/summary") {
const dateText = url.searchParams.get("date");
if (dateText && !isValidDateString(dateText)) {
sendJson(res, 400, { error: "日期格式错误,请使用 YYYY-MM-DD" });
return;
}
const data = await load();
const selected = applyFilters(data, url);
const total = selected.reduce((sum, item) => sum + item.amount, 0);
const income = selected
.filter((x) => x.amount >= 0)
.reduce((sum, item) => sum + item.amount, 0);
const expense = selected
.filter((x) => x.amount < 0)
.reduce((sum, item) => sum + item.amount, 0);
sendJson(res, 200, { total, count: selected.length, income, expense });
return;
}
if (req.method === "GET" && pathname === "/api/stats/categories") {
const data = applyFilters(await load(), url);
const bucket = {};
for (const item of data) {
const key = item.category || "未分类";
bucket[key] = (bucket[key] || 0) + item.amount;
}
const items = Object.entries(bucket)
.map(([name, total]) => ({ name, total }))
.sort((a, b) => Math.abs(b.total) - Math.abs(a.total));
sendJson(res, 200, { items });
return;
}
if (req.method === "POST" && pathname === "/api/entries") {
const body = await readJsonBody(req);
const amount = Number(body.amount);
const note = String(body.note ?? "").trim();
const category = String(body.category ?? "未分类").trim() || "未分类";
if (Number.isNaN(amount)) {
sendJson(res, 400, { error: "amount 必须是数字" });
return;
}
if (!note) {
sendJson(res, 400, { error: "备注不能为空" });
return;
}
const data = await load();
const item = {
id: Date.now(),
amount,
note,
category,
createdAt: new Date().toISOString(),
};
data.push(item);
await save(data);
sendJson(res, 201, { item });
return;
}
if (req.method === "PUT" && pathname.startsWith("/api/entries/")) {
const idText = pathname.split("/").pop();
const id = Number(idText);
if (!idText || Number.isNaN(id)) {
sendJson(res, 400, { error: "id 必须是数字" });
return;
}
const body = await readJsonBody(req);
const amount = Number(body.amount);
const note = String(body.note ?? "").trim();
const category = String(body.category ?? "未分类").trim() || "未分类";
if (Number.isNaN(amount) || !note) {
sendJson(res, 400, { error: "参数错误" });
return;
}
const data = await load();
const idx = data.findIndex((x) => x.id === id);
if (idx === -1) {
sendJson(res, 404, { error: "未找到该 id" });
return;
}
data[idx] = { ...data[idx], amount, note, category };
await save(data);
sendJson(res, 200, { item: data[idx] });
return;
}
if (req.method === "DELETE" && pathname.startsWith("/api/entries/")) {
const idText = pathname.split("/").pop();
const id = Number(idText);
if (!idText || Number.isNaN(id)) {
sendJson(res, 400, { error: "id 必须是数字" });
return;
}
const data = await load();
const next = data.filter((x) => x.id !== id);
if (next.length === data.length) {
sendJson(res, 404, { error: "未找到该 id" });
return;
}
await save(next);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "GET") {
await serveStaticFile(res, pathname);
return;
}
sendJson(res, 404, { error: "not found" });
} catch (err) {
sendJson(res, 500, { error: err.message || "internal error" });
}
});
server.listen(port, () => {
console.log(`API 服务已启动: http://localhost:${port}`);
});
}
async function main() {
const data = await load();
if (cmd === "serve") {
const port = Number(args[0]) || 3001;
startApiServer(port);
return;
}
if (cmd === "add") {
const [amountText, ...noteParts] = args;
const amount = Number(amountText);
const note = noteParts.join(" ").trim();
if (!amountText || Number.isNaN(amount)) {
console.log("amount 必须是数字");
return;
}
if (!note) {
console.log("备注不能为空");
return;
}
const item = {
id: Date.now(),
amount,
note,
createdAt: new Date().toISOString(),
};
data.push(item);
await save(data);
console.log("已添加:", item);
return;
}
if (cmd === "remove") {
const id = Number(args[0]);
if (!args[0] || Number.isNaN(id)) {
console.log("id 必须是数字");
return;
}
const next = data.filter((x) => x.id !== id);
if (next.length === data.length) {
console.log("未找到该 id:", id);
return;
}
await save(next);
console.log("删除成功:", id);
return;
}
if (cmd === "list" || cmd === "sum") {
const dateText = pickDateArg(args);
if (dateText && !isValidDateString(dateText)) {
console.log("日期格式错误,请使用 YYYY-MM-DD");
return;
}
const selected = withDateFilter(data, dateText);
if (cmd === "list") {
console.table(selected);
return;
}
const total = selected.reduce((s, x) => s + x.amount, 0);
console.log("总计:", total);
return;
}
printHelp();
}
main().catch((err) => {
console.error("程序出错:", err.message);
});