前言:
技术栈选择(最通用版)
数据库:MySQL
后端:Java (Spring Boot)
前端:HTML + Vue.js (通过 CDN 引入,无需配置 Webpack,单文件即可运行,最适合练习)
第一步:数据库设计 (SQL)
注:未安装MySql可参考上一篇Mysql的常用指令
在你的 MySQL 中执行以下 SQL,创建表和初始数据:
sql
-- 1. 创建数据库
CREATE DATABASE IF NOT EXISTS expense_tracker DEFAULT CHARACTER SET utf8mb4;
USE expense_tracker;
-- 2. 创建记账表
CREATE TABLE expenses (
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
title VARCHAR(100) NOT NULL COMMENT '消费标题,如:午餐、打车',
amount DECIMAL(10, 2) NOT NULL COMMENT '金额,保留两位小数',
category VARCHAR(50) DEFAULT '其他' COMMENT '分类:餐饮、交通、购物等',
expense_date DATE NOT NULL COMMENT '消费日期',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间'
);
-- 3. 插入几条测试数据
INSERT INTO expenses (title, amount, category, expense_date) VALUES
('午餐-麦当劳', 35.00, '餐饮', '2023-10-25'),
('地铁充值', 50.00, '交通', '2023-10-26'),
('买书-深入理解Java', 89.00, '学习', '2023-10-27');
第二步:后端接口设计 (RESTful API)
你需要提供 4 个核心接口。后端运行在本地环境http://localhost:8080
功能
| HTTP | 方法 | URL 路径 | 描述 |
|---|---|---|---|
| Create | POST | /api/expenses | 新增一笔账单 |
| Read (List) | GET | /api/expenses | 获取所有账单列表(支持搜索) |
| Update | PUT | /api/expenses/{id} | 修改某笔账单 |
| Delete | DELETE | /api/expenses/{id} | 新增一笔账单 |
后端关键代码逻辑(Java 思路)
此次我们采用常见的Spring Boot 结构。
可通过官网Spring快速创建,也可自己搭建。
文件结构目录如下:

src/
└── main/
├── java/
│ └── com.example.demo/ ← 主包名(必须与 DemoApplication.java 所在包一致)
│ ├── config/ ← 配置类包
│ ├── controller/ ← 控制器层(接收 HTTP 请求)
│ ├── entity/ ← 实体类包(对应数据库表)
│ ├── repository/ ← 数据访问层(操作数据库)
│ ├── service/ ← 业务逻辑层
│ │ └── impl/ ← 服务实现类
│ ├── DemoApplication.java ← 启动类(入口)
│ └── HelloController.java ← 测试用控制器(可删除)
└── resources/
└── application.properties ← 配置文件
1.com.example.demo.DemoApplication.java ------ 启动类(Entry Point)
作用 :Spring Boot 应用的入口点 。
内容如下:
java
@SpringBootApplication
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
重要性:
自动扫描 当前包及其子包下的所有组件(@Component, @Service, @Controller, @Repository 等)。
启动内嵌 Tomcat 服务器。
初始化 Spring 容器(IoC/DI)。
⚠️ 注意:其他所有类都必须放在这个包或其子包下,否则不会被扫描到!
2. config/WebConfig.java ------ 配置类(Configuration)
作用:自定义 Spring MVC 行为,比如 CORS 跨域 、拦截器、视图解析器等。
内容如下:
java
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*") // ✅ 改用 allowedOriginPatterns,支持通配符 + 凭证
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true); // 允许携带 Cookie/Auth Header
}
}
这段代码的作用是:告诉 Spring Boot,"允许任何网站通过浏览器访问我的 /api/** 接口,并且可以携带 Cookie 或 Token",从而解决前后端分离开发中最常见的"跨域问题"。
为什么单独放 config 包?
分离关注点:配置 ≠ 业务逻辑。
易于维护:所有全局配置集中管理。
符合单一职责原则。
3.controller/ExpenseController.java ------ 控制层(Presentation Layer)
作用:
- 接收前端 HTTP 请求(GET/POST/PUT/DELETE)。
- 调用 Service 层处理业务。
- 返回响应给前端(JSON /String / View)。
内容如下:
java
package com.example.demo.controller;
import com.example.demo.entity.Expense;
import com.example.demo.service.ExpenseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
// 1. 标记这是一个 REST 控制器,所有方法返回值直接作为 HTTP 响应体
@RestController
// 2. 定义统一的路径前缀,所有接口都以 /api/expenses 开头
@RequestMapping("/api/expenses")
public class ExpenseController {
// 3. 注入 Service 层接口(Spring 会自动找到 ExpenseServiceImpl)
@Autowired
private ExpenseService expenseService;
/**
* R: Read (查询)
* GET /api/expenses?search=xxx
*/
@GetMapping
public List<Expense> getAllExpenses(@RequestParam(required = false) String search) {
// 调用 Service 层的查询方法,支持可选的搜索参数
return expenseService.findAll(search);
}
/**
* C: Create (新增)
* POST /api/expenses
* Body: JSON 对象 { "title": "...", "amount": ... }
*/
@PostMapping
public ResponseEntity<String> createExpense(@RequestBody Expense expense) {
try {
// 调用 Service 层保存数据
expenseService.save(expense);
// 返回 201 Created 状态码和成功消息
return ResponseEntity.status(201).body("Expense created successfully");
} catch (Exception e) {
// 简单异常处理
return ResponseEntity.status(500).body("Error creating expense: " + e.getMessage());
}
}
/**
* U: Update (更新)
* PUT /api/expenses/{id}
* Path Variable: id
* Body: JSON 对象 { "title": "...", "amount": ... }
*/
@PutMapping("/{id}")
public ResponseEntity<String> updateExpense(@PathVariable Integer id, @RequestBody Expense expense) {
try {
// 设置 ID,确保 Service 知道要更新哪条记录
expense.setId(id);
// 调用 Service 层更新数据
expenseService.update(expense);
return ResponseEntity.ok("Expense updated successfully");
} catch (Exception e) {
return ResponseEntity.status(500).body("Error updating expense: " + e.getMessage());
}
}
/**
* D: Delete (删除)
* DELETE /api/expenses/{id}
* Path Variable: id
*/
@DeleteMapping("/{id}")
public ResponseEntity<String> deleteExpense(@PathVariable Integer id) {
try {
// 调用 Service 层删除数据
expenseService.deleteById(id);
return ResponseEntity.ok("Expense deleted successfully");
} catch (Exception e) {
return ResponseEntity.status(500).body("Error deleting expense: " + e.getMessage());
}
}
}
命名规范 :以 Controller 结尾,如 UserController, ProductController。
职责边界:
❌ 不应该写业务逻辑(如计算总额、验证规则)。
✅ 只负责参数接收、调用 Service、返回结果。
4. entity/Expense.java ------ 实体类(Domain Model / POJO)
作用 :
对应数据库中的一张表(expenses)。
封装数据字段 + getter/setter。
添加 JPA 注解映射关系。
内容如下:
java
package com.example.demo.entity;
import jakarta.persistence.*; // 注意:Spring Boot 3.x 用 jakarta,2.x 用 javax
import java.math.BigDecimal;
import java.time.LocalDate;
// ✅ 1. 标记这是一个 JPA 实体,对应数据库表 'expenses'
@Entity
@Table(name = "expenses")
public class Expense {
// ✅ 2. 标记为主键,且自增
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String title;
// columnDefinition 确保金额精度正确
@Column(precision = 10, scale = 2)
private BigDecimal amount;
private String category;
// ✅ 3. 映射日期字段
private LocalDate expenseDate;
// Getters and Setters (必须保留,JPA 需要通过它们读写数据)
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public LocalDate getExpenseDate() { return expenseDate; }
public void setExpenseDate(LocalDate expenseDate) { this.expenseDate = expenseDate; }
}
5 repository/ExpenseRepository.java ------ 数据访问层(DAO / Repository)
作用 :
直接与数据库交互(CRUD 操作)。
继承 JpaRepository<Expense, Integer> 获得内置方法。
可定义自定义查询方法(如 findByTitleContaining)。
内容如下:
java
package com.example.demo.repository;
import com.example.demo.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
// ✅ 继承 JpaRepository,泛型分别是 <实体类, 主键类型>
@Repository
public interface ExpenseRepository extends JpaRepository<Expense, Integer> {
// ✅ 神奇之处:只要方法名符合规范,JPA 自动生成 SQL!
// 这个方法会自动生成: SELECT * FROM expenses WHERE title LIKE %search%
List<Expense> findByTitleContainingIgnoreCase(String search);
}
为什么用 Interface?
Spring Data JPA 会自动生成实现类(无需手写 SQL)。
支持方法名推导查询(Convention over Configuration)。
优势:
减少样板代码。
类型安全。
支持分页、排序、投影等高级特性。
6. service/ExpenseService.java ------ 业务接口(Business Contract)
作用 :
定义业务逻辑的"契约"。
被 Controller 注入使用。
便于后续替换实现(如换缓存、换消息队列)。
内容如下:
java
package com.example.demo.service;
import com.example.demo.entity.Expense;
import java.util.List;
public interface ExpenseService {
List<Expense> findAll(String search);
void save(Expense expense);
void update(Expense expense);
void deleteById(Integer id);
}
为什么要有接口?
解耦 :Controller 不依赖具体实现。
可测试 :可以用 Mock 实现进行单元测试。
可扩展:未来可以加事务、日志、权限等 AOP 切面。
7.service/impl/ExpenseServiceImpl.java ------ 业务实现类(Business Logic)
作用:
实现 ExpenseService 接口。
包含真正的业务逻辑(如校验、计算、组合多个 Repository 操作)。
调用 Repository 层完成数据持久化。
内容如下:
java
package com.example.demo.service.impl;
import com.example.demo.entity.Expense;
import com.example.demo.repository.ExpenseRepository;
import com.example.demo.service.ExpenseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ExpenseServiceImpl implements ExpenseService {
// ✅ 注入 Repository,而不是自己 new 一个 List
@Autowired
private ExpenseRepository expenseRepository;
@Override
public List<Expense> findAll(String search) {
if (search == null || search.isEmpty()) {
// 查询所有,按 ID 倒序(可选)
return expenseRepository.findAll();
} else {
// 调用我们在 Repository 中定义的自定义查询方法
return expenseRepository.findByTitleContainingIgnoreCase(search);
}
}
@Override
public void save(Expense expense) {
// save 方法:如果 ID 为空则插入,如果 ID 存在则更新
expenseRepository.save(expense);
}
@Override
public void update(Expense expense) {
// 同样使用 save,因为 JPA 会根据 ID 判断是更新还是插入
expenseRepository.save(expense);
}
@Override
public void deleteById(Integer id) {
expenseRepository.deleteById(id);
}
}
为什么分 impl 子包?
清晰区分接口与实现。
避免混淆(尤其当有多个实现时)。
符合 Maven/Gradle 标准结构。
8.resources/application.properties ------ 配置文件
作用:
配置数据库连接、端口、日志级别、JPA 行为等。
关键配置:
java
spring.datasource.url=jdbc:mysql://localhost:3306/expense_tracker?...
spring.datasource.username=root
spring.datasource.password=xxx
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
server.port=8080
✅ 小贴士:为什么这种结构好?
高内聚低耦合 :每层职责明确,修改一层不影响其他层。
易于测试 :可以单独 Mock Service 或 Repository 进行单元测试。
易于扩展 :新增功能只需增加新 Controller/Service/Repository。
**团队协作友好:**不同开发者负责不同层,冲突少。
**符合行业最佳实践:**几乎所有大型 Java 项目都采用类似结构。
第三步:前端页面 (单文件 HTML + Vue)
**引入 Axios:**用于发送 HTTP 请求。
axios.get/post/put/delete。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>极简记账本</title>
<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 引入 Axios (关键!用于发请求) -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; max-width: 900px; margin: 30px auto; padding: 20px; background-color: #f4f6f8; }
h2 { color: #333; text-align: center; }
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); margin-bottom: 20px; }
.form-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
input, select { padding: 10px; border: 1px solid #ddd; border-radius: 4px; flex: 1; min-width: 120px; }
button { padding: 10px 20px; cursor: pointer; border: none; border-radius: 4px; color: white; font-weight: bold; transition: 0.3s; }
.btn-add { background-color: #4CAF50; }
.btn-add:hover { background-color: #45a049; }
.btn-edit { background-color: #2196F3; margin-right: 5px; padding: 5px 10px; font-size: 0.9em;}
.btn-del { background-color: #f44336; padding: 5px 10px; font-size: 0.9em;}
table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
th, td { border: 1px solid #eee; padding: 12px; text-align: left; }
th { background-color: #f8f9fa; color: #555; }
tr:nth-child(even) { background-color: #f9f9f9; }
.total-box { margin-top: 20px; font-weight: bold; font-size: 1.2em; text-align: right; color: #d32f2f; }
.loading { color: #666; font-style: italic; }
</style>
</head>
<body>
<div id="app">
<h2>💰 极简记账本 (Connected to MySQL)</h2>
<!-- 表单区域 -->
<div class="card">
<div class="form-group">
<input v-model="form.title" placeholder="消费标题 (如: 午餐)" style="flex: 2;">
<input v-model.number="form.amount" type="number" step="0.01" placeholder="金额" style="width: 100px;">
<select v-model="form.category">
<option value="餐饮">餐饮</option>
<option value="交通">交通</option>
<option value="购物">购物</option>
<option value="其他">其他</option>
</select>
<input v-model="form.expenseDate" type="date">
<button v-if="!isEditing" @click="addExpense" class="btn-add">➕ 新增</button>
<button v-else @click="updateExpense" class="btn-add" style="background-color: #ff9800;">💾 保存修改</button>
<button v-if="isEditing" @click="cancelEdit" style="background-color: #9e9e9e;">❌ 取消</button>
</div>
</div>
<!-- 搜索栏 -->
<div class="card" style="padding: 10px 20px;">
<input v-model="searchQuery" @input="fetchExpenses" placeholder="🔍 搜索标题..." style="width: 100%;">
</div>
<!-- 数据列表 -->
<div class="card" style="padding: 0;">
<table v-if="!loading">
<thead>
<tr>
<th>ID</th>
<th>日期</th>
<th>标题</th>
<th>分类</th>
<th>金额</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in expenses" :key="item.id">
<td>{{ item.id }}</td>
<td>{{ item.expenseDate }}</td>
<td>{{ item.title }}</td>
<td><span style="background:#e3f2fd; padding:2px 6px; border-radius:4px; font-size:0.8em; color:#1565c0;">{{ item.category }}</span></td>
<td style="color: #d32f2f; font-weight: bold;">¥ {{ item.amount.toFixed(2) }}</td>
<td>
<button @click="editExpense(item)" class="btn-edit">✏️ 编辑</button>
<button @click="deleteExpense(item.id)" class="btn-del">️ 删除</button>
</td>
</tr>
<tr v-if="expenses.length === 0">
<td colspan="6" style="text-align: center; color: #999; padding: 20px;">暂无数据,快去添加一笔吧!</td>
</tr>
</tbody>
</table>
<div v-else class="loading" style="text-align: center; padding: 20px;">加载中...</div>
</div>
<div class="total-box">
总支出: ¥ {{ totalAmount.toFixed(2) }}
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
// 配置后端 API 地址
const API_URL = 'http://localhost:8080/api/expenses';
createApp({
setup() {
const expenses = ref([]);
const searchQuery = ref('');
const loading = ref(false);
const defaultForm = {
id: null,
title: '',
amount: '',
category: '餐饮',
expenseDate: new Date().toISOString().split('T')[0]
};
const form = ref({ ...defaultForm });
const isEditing = ref(false);
// R: 获取数据 (支持搜索)
const fetchExpenses = async () => {
loading.value = true;
try {
// 发送 GET 请求,带上搜索参数
const response = await axios.get(API_URL, {
params: { search: searchQuery.value }
});
expenses.value = response.data;
} catch (error) {
console.error("获取数据失败:", error);
alert("获取数据失败,请检查后端是否启动");
} finally {
loading.value = false;
}
};
// C: 新增数据
const addExpense = async () => {
if (!form.value.title || !form.value.amount) return alert("请填写完整信息");
try {
// 发送 POST 请求
await axios.post(API_URL, form.value);
alert("添加成功!");
resetForm();
fetchExpenses(); // 重新加载列表
} catch (error) {
console.error("添加失败:", error);
alert("添加失败: " + (error.response?.data || error.message));
}
};
// 准备编辑
const editExpense = (item) => {
// 注意:后端返回的字段名如果是 camelCase (expenseDate),这里要对应
// 如果后端返回的是 snake_case (expense_date),需要转换或修改后端
form.value = {
id: item.id,
title: item.title,
amount: item.amount,
category: item.category,
expenseDate: item.expenseDate // 确保格式是 YYYY-MM-DD
};
isEditing.value = true;
};
// U: 更新数据
const updateExpense = async () => {
try {
// 发送 PUT 请求,URL 中包含 ID
await axios.put(`${API_URL}/${form.value.id}`, form.value);
alert("更新成功!");
resetForm();
fetchExpenses();
} catch (error) {
console.error("更新失败:", error);
alert("更新失败");
}
};
// D: 删除数据
const deleteExpense = async (id) => {
if (!confirm("确定要删除这条记录吗?")) return;
try {
// 发送 DELETE 请求
await axios.delete(`${API_URL}/${id}`);
alert("删除成功!");
fetchExpenses();
} catch (error) {
console.error("删除失败:", error);
alert("删除失败");
}
};
const resetForm = () => {
form.value = { ...defaultForm, expenseDate: new Date().toISOString().split('T')[0] };
isEditing.value = false;
};
const cancelEdit = () => resetForm();
const totalAmount = computed(() => {
return expenses.value.reduce((sum, item) => sum + Number(item.amount), 0);
});
// 页面加载时自动获取数据
onMounted(() => {
fetchExpenses();
});
return {
expenses,
form,
searchQuery,
loading,
isEditing,
addExpense,
editExpense,
updateExpense,
deleteExpense,
cancelEdit,
fetchExpenses,
totalAmount
};
}
}).mount('#app');
</script>
</body>
</html>
一个简易的个人记账本小项目就完成啦!