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 "); 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); });