chore: init ledger project with docs and gitignore
This commit is contained in:
352
index.js
Normal file
352
index.js
Normal file
@@ -0,0 +1,352 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user