加 POST 新增账单功能。
改文件:
app/api/transactions/route.ts
现在这个文件里已经有 GET(),我们就在它下面继续加 POST()。
把下面这段放到 GET() 后面:
export async function POST(request: Request) {
try {
const body = await request.json();
const {
type,
amount,
category,
note,
transaction_date,
} = body;
const [result] = await pool.query(
`
INSERT INTO transactions
(type, amount, category, note, transaction_date)
VALUES (?, ?, ?, ?, ?)
`,
[
type,
amount,
category,
note || null,
transaction_date,
]
);
return NextResponse.json(
{
message: "新增账单成功",
result,
},
{ status: 201 }
);
} catch (error) {
console.error("新增账单失败:", error);
return NextResponse.json(
{ error: "新增账单失败" },
{ status: 500 }
);
}
}
这样你的这个接口就同时支持:
GET /api/transactions
→ 查询账单
POST /api/transactions
→ 新增账单
测试 POST 接口本身能不能成功插入数据
保持 npm run dev 终端运行,然后新终端执行:
Invoke-RestMethod `
-Uri "http://localhost:3000/api/transactions" `
-Method POST `
-ContentType "application/json" `
-Body '{
"type": "expense",
"amount": 18.5,
"category": "餐饮",
"note": "测试晚饭",
"transaction_date": "2026-08-14"
}'
如果成功,你应该会看到类似:
message
-------
新增账单成功
然后再打开:
http://localhost:3000/api/transactions
刷新。
如果里面多出一条:
{
"type": "expense",
"amount": "18.50",
"category": "餐饮",
"note": "测试晚饭"
}
那就代表:
POST → Next.js → SQL INSERT → MySQL
整条新增链路正式跑通了。
浏览器中文写入 MySQL
打开:
app/page.tsx
原来的:
const handleSave = () => {
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return;
}
const newTransaction: Transaction = {
id: Date.now(),
title: note.trim() !== "" ? note.trim() : category,
amount: type === "income" ? parsedAmount : -parsedAmount,
date: "今天",
category,
note: note.trim() !== "" ? note.trim() : undefined,
};
setTransactions((prev) => [newTransaction, ...prev]);
setIsModalOpen(false);
resetForm();
};
目前只是:
点击保存 → 改 React 内存里的
transactions
所以刷新网页以后数据就没了。
我们要把它改成:
点击保存 → POST
/api/transactions→ MySQL 真正保存
可以改成这样:
const handleSave = async () => {
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return;
}
const today = new Date().toISOString().slice(0, 10);
const response = await fetch("/api/transactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type,
amount: parsedAmount,
category,
note,
transaction_date: today,
}),
});
const data = await response.json();
if (!response.ok) {
console.error("新增账单失败:", data);
return;
}
const newTransaction: Transaction = {
id: Date.now(),
title: note.trim() !== "" ? note.trim() : category,
amount: type === "income" ? parsedAmount : -parsedAmount,
date: "今天",
category,
note: note.trim() !== "" ? note.trim() : undefined,
};
setTransactions((prev) => [newTransaction, ...prev]);
setIsModalOpen(false);
resetForm();
};
这样就同时做两件事:
点击保存
↓
POST 到后端
↓
MySQL 真正新增一条账单
↓
前端也立即显示这条账单
点一次「+ 记一笔」,输入一笔中文账单测试。成功后再去 /api/transactions 看它是不是真的进入 MySQL。
首页 GET MySQL 真实账单,而不是 mock 数据。
前端 Transaction 类型还没完全对齐,先对齐
真实数据库是:
id bigint(20)
type enum('income','expense')
amount decimal(10,2)
category varchar(50)
note varchar(255) / 可为空
transaction_date date
created_at timestamp
所以app/page.tsx现在这段:
type Transaction = {
id: number;
title: string;
amount: number;
date: string;
category: string;
note?: string;
};
需要逐步改成更接近数据库真实结构的版本。
你先把它替换成:
type Transaction = {
id: number;
type: "income" | "expense";
amount: number;
category: string;
note: string | null;
transaction_date: string;
created_at: string;
};
这样对应关系就是:
数据库 前端
id ↔ id
type ↔ type
amount ↔ amount
category ↔ category
note ↔ note
transaction_date ↔ transaction_date
created_at ↔ created_at
把模拟数据 mockTransactions 去掉。
在同一个 app/page.tsx 里,找到这一段:
const mockTransactions: Transaction[] = [
{ id: 1, title: "工资", amount: 300, date: "今天", category: "工资" },
{ id: 2, title: "吃饭", amount: -25, date: "今天", category: "餐饮" },
{ id: 3, title: "日用品", amount: -48, date: "昨天", category: "日用品" },
{ id: 4, title: "交通", amount: -20, date: "昨天", category: "交通" },
];
整段删掉。
然后再找到:
const [transactions, setTransactions] =
useState<Transaction[]>(mockTransactions);
改成:
const [transactions, setTransactions] =
useState<Transaction[]>([]);
意思就是:
页面刚打开时先没有账单,后面我们再从 MySQL 真实读取。
让页面打开时去 GET /api/transactions,把 MySQL 里的真实账单读进来。
还是改 app/page.tsx。
先把最上面的:
import { useState } from "react";
改成:
import { useEffect, useState } from "react";
然后在 Home() 里面,放在这段后面:
const [transactions, setTransactions] =
useState<Transaction[]>([]);
加上:
useEffect(() => {
async function loadTransactions() {
const response = await fetch("/api/transactions");
const data = await response.json();
const formatted = data.map((item: Transaction) => ({
...item,
amount: Number(item.amount),
}));
setTransactions(formatted);
}
loadTransactions();
}, []);
因为 Transaction 类型已经改成数据库真实字段了,下面还有几处旧逻辑没跟着改,所以接下来要修这几块。
第一处,handleSave() 里这个 newTransaction 现在还是旧结构:
const newTransaction: Transaction = {
id: Date.now(),
title: note.trim() !== "" ? note.trim() : category,
amount: type === "income" ? parsedAmount : -parsedAmount,
date: "今天",
category,
note: note.trim() !== "" ? note.trim() : undefined,
};
这里要改成数据库字段对应的结构:
const newTransaction: Transaction = {
id: Date.now(),
type,
amount: parsedAmount,
category,
note: note.trim() !== "" ? note.trim() : null,
transaction_date: today,
created_at: new Date().toISOString(),
};
第二处,你现在收入支出统计还在根据正负金额判断:
const monthlyIncome = transactions
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const monthlyExpense = transactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
但我们数据库的规则是:
金额永远存正数,收入支出靠
type区分。
所以改成:
const monthlyIncome = transactions
.filter((t) => t.type === "income")
.reduce((sum, t) => sum + t.amount, 0);
const monthlyExpense = transactions
.filter((t) => t.type === "expense")
.reduce((sum, t) => sum + t.amount, 0);
第三处,最近账单这里还在使用数据库里根本不存在的:
t.title
t.date
把:
<p className="font-medium text-gray-800">{t.title}</p>
<p className="text-xs text-gray-400">
{t.category} · {t.date}
</p>
改成:
<p className="font-medium text-gray-800">
{t.note || t.category}
</p>
<p className="text-xs text-gray-400">
{t.category} · {t.transaction_date}
</p>
第四处,金额颜色和正负号也要改。
现在是:
className={`font-semibold ${
t.amount > 0 ? "text-emerald-600" : "text-rose-600"
}`}
改成:
className={`font-semibold ${
t.type === "income" ? "text-emerald-600" : "text-rose-600"
}`}
然后:
{t.amount > 0 ? "+" : "-"}¥{Math.abs(t.amount).toFixed(2)}
改成:
{t.type === "income" ? "+" : "-"}¥{t.amount.toFixed(2)}
这样整个前端就真正和数据库统一了:
一个小问题修改
handleSave() 成功之后现在是自己构造:
const newTransaction: Transaction = {
id: Date.now(),
...
};
这里的 id: Date.now() 只是前端临时造的,不是数据库真正的自增 id。短期能显示,但以后做删除/修改时会出问题。
第一步:把读取账单单独变成一个函数
你现在有:
useEffect(() => {
async function loadTransactions() {
const response = await fetch("/api/transactions");
const data = await response.json();
const formatted = data.map((item: Transaction) => ({
...item,
amount: Number(item.amount),
}));
setTransactions(formatted);
}
loadTransactions();
}, []);
把它整段替换成:
const loadTransactions = async () => {
const response = await fetch("/api/transactions");
const data = await response.json();
const formatted = data.map((item: Transaction) => ({
...item,
amount: Number(item.amount),
}));
setTransactions(formatted);
};
useEffect(() => {
loadTransactions();
}, []);
为什么这么改?
以前 loadTransactions() 被关在 useEffect 里面:
useEffect
└── loadTransactions
外面的 handleSave() 用不了它。
现在把它拿出来:
Home
├── loadTransactions()
├── useEffect()
└── handleSave()
这样:
- 页面第一次打开 → 可以调用它
- 新增成功 → 也可以调用它
- 以后删除成功 → 还可以调用它
相当于我们把"刷新账单列表"做成了一个公共小工具。
第二步:修改 handleSave
你现在 handleSave 里面 POST 成功之后,有这一大段:
const newTransaction: Transaction = {
id: Date.now(),
type,
amount: parsedAmount,
category,
note: note.trim() !== "" ? note.trim() : null,
transaction_date: today,
created_at: new Date().toISOString(),
};
setTransactions((prev) => [newTransaction, ...prev]);
setIsModalOpen(false);
resetForm();
把这里面的:
const newTransaction...
和:
setTransactions(...)
全部删掉。
改成:
await loadTransactions();
setIsModalOpen(false);
resetForm();
所以完整的 handleSave() 最后应该变成:
const handleSave = async () => {
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return;
}
const today = new Date().toISOString().slice(0, 10);
const response = await fetch("/api/transactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type,
amount: parsedAmount,
category,
note,
transaction_date: today,
}),
});
const data = await response.json();
if (!response.ok) {
console.error("新增账单失败:", data);
return;
}
await loadTransactions();
setIsModalOpen(false);
resetForm();
};
测试
如果开发服务器没开:
npm run dev
然后打开:
http://localhost:3000
先观察首页。
正常情况下,你应该直接看到数据库里的真实账单,而不是之前的「工资300、吃饭25、日用品48、交通20」那四条 mock 数据。
目前数据库里我们知道至少有:
25.00 餐饮 午饭
18.50 ?? ????
3.00 餐饮
所以如果看到这些,说明 GET → MySQL → 首页 成功。
然后再点:
「+ 记一笔」
这次建议故意测试一条收入,因为之前基本都在测试支出:
类型:收入
金额:100
分类:兼职
备注:测试兼职收入
点「保存」。
正常应该马上出现:
测试兼职收入
兼职 · 日期
+¥100.00
而且应该是绿色。
同时顶部:
本月收入
+¥100.00
然后按 F5 刷新网页。
如果刷新之后这条 100 元兼职收入 还在 ,这就非常关键了------说明它不是 React 临时显示,而是真的进了 MySQL。
