表单的完整 CRUD 练习【极简个人记账本】(含前端后端链接mySQL)

前言:

技术栈选择(最通用版)

数据库: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>

一个简易的个人记账本小项目就完成啦!

相关推荐
幽络源小助理1 小时前
MacCMSPro版视频影视系统源码_全开源高可用视频平台解决方案
前端·php·php源码
通往曙光的路上1 小时前
mysql1
java
2301_809204703 小时前
bootstrap怎么实现鼠标悬停切换图片预览功能
jvm·数据库·python
Tigshop开源商城6 小时前
『物流设置+SEO优化』Tigshop开源商城系统 JAVA v5.8.26 版本更新!
java·开源商城系统·tigshop
小短腿的代码世界8 小时前
Qt 股票订单撮合引擎:高频交易系统的核心心脏
开发语言·数据库·qt·系统架构·交互
Tigshop开源商城8 小时前
『订单税率+收货地址校验国家字段』功能上新|跨境运营更高效,Tigshop开源商城系统 JAVA v5.8.23 版本更新
java·开源商城系统·tigshop
不会敲代码18 小时前
手写 Zustand:三十分钟带你搞懂状态管理库的核心原理
前端·javascript·源码
神奇的程序员8 小时前
重构了自己5年前写的截图插件
前端·javascript·架构
REDcker8 小时前
C++变量存储与ELF段布局详解 从const全局到rodata与nm_readelf验证实践
java·c++·面试