353 lines
11 KiB
JavaScript
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);
|
|
});
|