☰
SpringBoot+Vue3美食菜谱管理系统:前后端分离实战教程
2026/9/28 5:08:52 网站建设 项目流程

正在开发一个管理系统类项目时,你在招聘网站上投出去的第一批简历,十有八九会卡在“项目经验”这一栏。很多应届生和转行者都会在某个深夜冒出同一个念头:该做一个什么样的项目,既能满足毕业设计的要求,又能让面试官愿意多问两句?如果你搜索过这个问题的答案,大概率会看到一个组合词:SpringBoot + Vue3 前后端分离。这套技术栈几乎是当前国内 Java 开发岗位的默认配置,而用“美食菜谱管理系统”作为业务载体,则是这个组合里性价比极高的选题。

这篇文章不会停留在“教你敲一遍增删改查”的层面。我会把从数据库设计、后端接口、前端页面到联调排错的完整链路拆开讲清楚,让一个已经有 Java 基础的读者,能在两小时左右跑通整个系统,并真正理解前后端分离项目里那些“看起来简单、一上手就卡住”的环节究竟是怎么回事。如果你正在准备毕业设计,或者想在简历里补上一个能经得起追问的全栈项目,这篇文章值得看完并收藏。

先说一个判断:这类管理系统项目的核心壁垒从来不是代码量,而是你能不能把一张表、两个端、三套流程之间的数据流转讲明白。CRUD 谁都会背,但为什么前端发来的是 JSON 而数据库存的是行记录,为什么开发时没有跨域问题而部署后却有,这些问题才是面试官真正想听的。

1. 这个项目解决什么问题,适合谁来做

1.1 为什么管理系统类项目始终是 Java 初学者的首选

管理系统之所以经久不衰,不是因为它的技术含量高,而是因为它足够“完整”。一个标准的后台管理系统,天然包含数据库设计、服务端接口开发、前端页面渲染、数据交互与异常处理这几个关键环节。换句话说,它覆盖了企业级 Web 开发中最常见的工作流,而不会像纯算法题那样游离在真实开发之外。

很多同学的第一个项目是图书管理、学生管理,这些选题没有错,但问题在于重复度太高。当十个候选人里有八个都写过“图书管理 CRUD”时,除非你做得特别出彩,否则很难在简历筛选阶段给面试官留下印象。美食菜谱管理系统的优势在于:业务场景生活化,演示效果好,数据字段类型丰富(比如食材、步骤都可以用长文本),而且天然适合扩展图片、分类、收藏、评论等模块。一个听起来“有生活气息”的项目,在面试时更容易引导对话走向你准备过的方向。

1.2 这个项目真正训练你什么能力

表面上看,这个项目训练的是增删改查;往深一层看,它训练的是前后端数据契约。后端定义返回结构,前端按结构渲染页面,数据库按设计好的字段存储数据。三者之间只要有一环不一致,页面就会报错、数据就会丢失。这种“契约意识”是很多自学新手最缺的东西——他们往往能照着教程敲通代码,但脱离教程后一旦接口没对齐,就完全不知道从哪排查起。

所以,这篇文章的展开逻辑也遵循同一条链路:先定数据库表结构,再写后端接口,再写前端页面,最后统一联调。我们先把最小闭环跑通,再讨论扩展和面试表达。

1.3 适合什么基础的人上手

适合具备 Java 基础语法、了解 Spring Boot 注解的基本用法、知道 Vue 的基本组件概念,但没有完整做过前后端分离项目的读者。如果你连环境都还没装好,也不必紧张,只是“两小时”这个时间预期需要调整——多预留一个下午用来装 JDK、Maven、MySQL、Node.js 和相关 IDE 会更加实际。本文会在每一步标注“这一步在干什么”和“做错会出什么问题”,尽量让你少走弯路。

2. 系统功能规划与技术选型

2.1 功能模块拆解

我们做一个最小但完整的系统,核心功能就是围绕菜谱数据的增删改查。功能清单如下表所示。

功能模块功能说明涉及数据
菜谱列表展示分页展示全部菜谱,支持按菜名模糊搜索菜谱表记录
菜谱新增填写菜名、分类、食材、步骤等信息并保存insert 操作
菜谱详情查看单个菜谱的完整内容根据 id 查询
菜谱编辑对已有菜谱信息进行修改update 操作
菜谱删除删除不想要的菜谱数据delete 操作

这个功能集很“朴素”,但每一块都是后续扩展的地基。比如加上图片字段就能做封面上传,加上分类表就能做菜系筛选,加上用户表就能做登录收藏。建议先把这一版跑通,再按后续章节的思路逐步加功能。

2.2 技术栈选型与原因

层次技术选型选型理由
后端框架Spring Boot 2.7.x生态成熟、资料多、对初学者最友好
数据持久层MyBatis-Plus 3.5.x内置单表 CRUD 方法,大幅减少重复 SQL
数据库MySQL 5.7 或 8.x国内使用最广,部署资料丰富
前端框架Vue 3 + ViteVue 3 是当前主流,Vite 启动速度快
UI 组件库Element Plus适合后台管理系统,组件齐全、上手快
HTTP 工具Axios前后端分离项目中最常用的请求库

这里可能会有人问:为什么不直接用 Spring Boot 3?因为 Spring Boot 3 要求 JDK 17 起步,且对应的 MyBatis-Plus 依赖包名和配置方式都发生了变化,对基础尚不扎实的读者来说,光是处理依赖不兼容就要多花不少时间。先以 2.7.x 跑通流程,之后再往新版本迁移会顺利很多。“SpringBoot 版本太高”导致各种报错,是初学者集中踩坑的重灾区,文章后面专门花一节来讲。

2.3 前后端分离架构中的“两个应用”

理解这个项目,先要建立一张思维模型:后端是一个独立应用,跑在 8080 端口;前端是另一个独立应用,跑在 5173 端口。前端通过 HTTP 请求把操作意图发给后端,后端操作数据库后把结果返回给前端。开发时两个应用各跑各的,互相之间通过一种叫 CORS 跨域资源共享的机制来放宽限制,或者更推荐的做法是让 Vite 开发服务器把请求“代理”给后端,彻底绕开跨域问题。部署时,则把前端构建出来的静态文件交给 Nginx 或放到 Spring Boot 的静态资源目录下,由同一个站点对外提供服务。开发期分离、部署期整合,这是前后端分离最常见的落地形态。

3. 数据库设计与初始化

3.1 建库建表脚本

打开 MySQL 客户端(Navicat、DBeaver、命令行均可),执行下面的 SQL 脚本。注意字符集选择 utf8mb4,它能完整支持中文和表情符号,避免出现乱码。

CREATE DATABASE IF NOT EXISTS recipe_db DEFAULT CHARACTER SET utf8mb4; USE recipe_db; CREATE TABLE recipe ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', name VARCHAR(100) NOT NULL COMMENT '菜名', category VARCHAR(50) NOT NULL COMMENT '分类:家常菜/川菜/烘焙/汤羹', ingredients TEXT COMMENT '主要食材', steps TEXT COMMENT '制作步骤', cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图地址', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜谱表'; INSERT INTO recipe (name, category, ingredients, steps, cover_url) VALUES ('红烧肉', '家常菜', '五花肉500g、冰糖、葱姜蒜、八角', '1. 五花肉切块焯水;2. 炒糖色;3. 加调料炖煮40分钟', ''), ('水煮鱼', '川菜', '草鱼、豆芽、干辣椒、花椒', '1. 片鱼;2. 煮豆芽垫底;3. 鱼片滑煮;4. 淋热油', ''), ('戚风蛋糕', '烘焙', '鸡蛋5个、低筋面粉90g、牛奶60g', '1. 分蛋;2. 蛋黄糊;3. 打发蛋白;4. 混合烤制', '');

3.2 字段设计为什么这样定

先说 name 和 category。菜名用 VARCHAR(100) 足够,分类只要固定几个值即可,先用普通字段存储,后续需要复杂筛选时再升级为独立分类表。ingredients 和 steps 用 TEXT 而不是 VARCHAR,是因为食谱的食材清单和多步骤描述往往超过 255 个字符,如果一开始就设置得太短,后面保存长文本时会被 MySQL 直接截断。cover_url 在设计上只存“图片地址”而不存图片二进制内容,是因为图片文件本身应该交给文件服务器或对象存储去管理,数据库只需记住“去哪找这个文件”。create_time 让记录带上时间戳,列表按时间倒序排列时会很有用。

建表的顺序是项目的第一步,却最容易被忽略。如果后面接口写完才发现缺字段,改动成本会成倍增加。所以建议把这段 SQL 保存为一个 init.sql 文件,方便多次重复执行。

4. 后端开发:SpringBoot 基础工程

4.1 创建工程与 Maven 依赖

你可以用 IDEA 的 Spring Initializr 创建工程,也可以直接手动建一个 Maven 项目。核心依赖如下,把它们加入 pom.xml。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这里面有两处初学者容易出问题的地方。第一个是 MyBatis-Plus 的依赖坐标,Spring Boot 2.x 项目要用mybatis-plus-boot-starter;如果你的工程是 Spring Boot 3,则需要改成mybatis-plus-spring-boot3-starter,并且 version 也要相应调整。第二个是 mysql 驱动的 groupId,新版本驱动类推荐写成com.mysql.cj.jdbc.Driver,如果你的 MySQL 是 5.7,使用 8.0 驱动也是兼容的,一般不会出问题。

4.2 配置数据库连接

在 src/main/resources 下找到或新建 application.yml,配置端口、数据源和 MyBatis-Plus 基础设置。

server: port: 8080 spring: application: name: recipe-server datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/recipe_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这段配置的意义有三层。url 里的useUnicode=true&characterEncoding=utf8是解决中文乱码的关键;serverTimezone=Asia/Shanghai避免 MySQL 8 时区报错。map-underscore-to-camel-case让数据库列名 cover_url 自动映射到实体字段 coverUrl,省去了手写大量结果映射。log-impl会把 SQL 输出到控制台,联调时可以直接看到后端执行了什么语句,排查问题很有用。

4.3 实体类与 Mapper

对应数据库表,我们编写一个实体类,字段名使用驼峰命名,并通过注解与表建立映射。新建文件 src/main/java/com/example/recipe/entity/Recipe.java。

package com.example.recipe.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.util.Date; @Data @TableName("recipe") public class Recipe { @TableId(type = IdType.AUTO) private Long id; private String name; private String category; private String ingredients; private String steps; private String coverUrl; private Date createTime; }

接着编写 Mapper 接口。这里就能体会到 MyBatis-Plus 的省事之处:继承 BaseMapper 后,单表的 select、insert、update、delete 方法全部自动具备,不用写一行 SQL。新建 src/main/java/com/example/recipe/mapper/RecipeMapper.java。

package com.example.recipe.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.recipe.entity.Recipe; import org.apache.ibatis.annotations.Mapper; @Mapper public interface RecipeMapper extends BaseMapper<Recipe> { }

4.4 Service 业务层

Service 层负责组装业务逻辑。本项目的业务逻辑不算复杂,但分页参数处理、模糊搜索条件构造,还是放在 Service 里更清晰。新建 src/main/java/com/example/recipe/service/RecipeService.java。

package com.example.recipe.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.recipe.entity.Recipe; import com.example.recipe.mapper.RecipeMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class RecipeService { private final RecipeMapper recipeMapper; public IPage<Recipe> page(int page, int size, String keyword) { LambdaQueryWrapper<Recipe> wrapper = new LambdaQueryWrapper<>(); if (keyword != null && !keyword.trim().isEmpty()) { wrapper.like(Recipe::getName, keyword.trim()); } wrapper.orderByDesc(Recipe::getId); return recipeMapper.selectPage(new Page<>(page, size), wrapper); } public Recipe getById(Long id) { return recipeMapper.selectById(id); } public void create(Recipe recipe) { recipe.setId(null); recipeMapper.insert(recipe); } public void update(Recipe recipe) { recipeMapper.updateById(recipe); } public void delete(Long id) { recipeMapper.deleteById(id); } }

这段代码展示了条件构造器的用法:wrapper.like(Recipe::getName, keyword)在 keyword 非空时生成name LIKE '%keyword%'的查询条件;wrapper.orderByDesc(Recipe::getId)让新数据排前面。分页这里要注意,MyBatis-Plus 的分页功能需要配置拦截器才会真正生效,如果不配置,你会发现分页参数没起作用,数据会全量返回。因此还需要新建一个配置类。

新建 src/main/java/com/example/recipe/config/MybatisPlusConfig.java。

package com.example.recipe.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

4.5 Controller 接口层

Controller 负责暴露 HTTP 接口。这里使用 RESTful 风格:GET 查列表和详情,POST 新增,PUT 修改,DELETE删除。为了前端处理方便,统一包装返回结果。

先编写统一返回对象,新建 src/main/java/com/example/recipe/common/Result.java。

package com.example.recipe.common; import lombok.Data; @Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

再编写控制器,新建 src/main/java/com/example/recipe/controller/RecipeController.java。

package com.example.recipe.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import com.example.recipe.common.Result; import com.example.recipe.entity.Recipe; import com.example.recipe.service.RecipeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/recipe") @RequiredArgsConstructor public class RecipeController { private final RecipeService recipeService; @GetMapping public Result<IPage<Recipe>> page( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { return Result.ok(recipeService.page(page, size, keyword)); } @GetMapping("/{id}") public Result<Recipe> detail(@PathVariable Long id) { return Result.ok(recipeService.getById(id)); } @PostMapping public Result<Void> create(@RequestBody Recipe recipe) { recipeService.create(recipe); return Result.ok(null); } @PutMapping public Result<Void> update(@RequestBody Recipe recipe) { recipeService.update(recipe); return Result.ok(null); } @DeleteMapping("/{id}") public Result<Void> delete(@PathVariable Long id) { recipeService.delete(id); return Result.ok(null); } }

这里有几个细节值得注意。@RequestParam(required = false)表示 keyword 可以不传,这是搜索功能的关键。@RequestBody的作用是把前端传来的 JSON 字符串反序列化成 Java 对象,如果前端没传 JSON 而是传了表单格式,这里就会报错。@PathVariable则把 URL 路径中的 id 绑定到方法参数上,对应前端的“传一个 id 给后端删除”。

4.6 允许前端跨域请求

开发环境虽然会用 Vite 代理来避免跨域,但为了接口能被 Postman 或其他端口直接访问,还是建议加一个 CORS 配置。新建 src/main/java/com/example/recipe/config/CorsConfig.java。

package com.example.recipe.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 CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }

这一步的作用是告诉后端:来自 http://localhost:5173 的请求可以被放行。实际开发中,如果前端通过 Vite 代理访问后端,后端的 CORS 配置并不是必须的,但保留它能让接口调试更灵活,也顺便说明了跨域产生的原理。

到这里,后端基本完成。启动 Spring Boot 应用后,用浏览器访问 http://localhost:8080/api/recipe,如果能看到 JSON 数据,说明后端链路已经通了。

5. 前端开发:Vue3 搭建菜谱管理系统页面

5.1 初始化 Vue3 项目与安装依赖

前端工作区选择在项目根目录之外单独建一个文件夹,比如 recipe-web。打开命令行,执行以下命令创建 Vue3 项目。

npm create vite@latest recipe-web -- --template vue cd recipe-web npm install npm install axios element-plus

如果执行npm create vite@latest时提示交互选择,直接选择 vue 模板即可。npm install会按 package.json 安装基础依赖,另外安装的 axios 是 HTTP 请求库,element-plus 是组件库。安装完成后,项目目录下会出现 src 文件夹,后续的页面代码都放在这里。

这里要提醒一句:Node.js 版本过旧可能导致 Vite 启动失败,建议使用 Node 16 及以上版本。安装完 Node 后,可以在命令行执行node -v确认版本。

5.2 配置 Vite 开发代理

直接在前端调用http://localhost:8080会触发跨域,更优雅的做法是让 Vite 把/api开头的请求代理到后端。修改项目根目录下的 vite.config.js。

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

配置完成后,前端代码里请求/api/recipe时,实际会被转发到http://localhost:8080/api/recipe。由于浏览器里看到的域名始终是 localhost:5173,就不会产生跨域拦截问题了。

5.3 封装 Axios 请求

在 src/api 目录下新建 recipe.js,集中管理所有和后端接口相关的请求。

import axios from 'axios' const $http = axios.create({ baseURL: '/api', timeout: 10000 }) export function getRecipes(params) { return $http.get('/recipe', { params }) } export function getRecipeById(id) { return $http.get(`/recipe/${id}`) } export function createRecipe(data) { return $http.post('/recipe', data) } export function updateRecipe(data) { return $http.put('/recipe', data) } export function deleteRecipe(id) { return $http.delete(`/recipe/${id}`) }

这样封装的目的是把 HTTP 细节隔离在页面之外。页面组件只关心“调用哪个函数、传什么参数、拿到什么结果”,维护起来更清晰。如果后续新增接口,只需要在这个文件里加一个函数。

5.4 菜谱列表与增删改查组件

核心页面放在 src/views/RecipeList.vue。这个组件包含了搜索、表格、分页和新增/编辑弹窗,是一个比较典型的 Element Plus 后台页面组织方式。

<template> <div class="recipe-page"> <div class="toolbar"> <el-input v-model="keyword" placeholder="输入菜名搜索" clearable style="width: 260px" @keyup.enter="handleSearch" /> <el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="success" @click="openDialog()">新增菜谱</el-button> </div> <el-table :data="records" border stripe v-loading="loading"> <el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="name" label="菜名" min-width="140" /> <el-table-column prop="category" label="分类" width="110" /> <el-table-column prop="ingredients" label="主要食材" min-width="200" show-overflow-tooltip /> <el-table-column prop="steps" label="制作步骤" min-width="200" show-overflow-tooltip /> <el-table-column label="操作" width="180" fixed="right"> <template #default="{ row }"> <el-button size="small" @click="openDialog(row)">编辑</el-button> <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" layout="total, prev, pager, next, sizes" :page-sizes="[5, 10, 20]" @current-change="loadData" @size-change="loadData" style="margin-top: 16px; justify-content: flex-end" /> <el-dialog v-model="dialogVisible" :title="form.id ? '编辑菜谱' : '新增菜谱'" width="560px"> <el-form :model="form" label-width="90px"> <el-form-item label="菜名" required> <el-input v-model="form.name" placeholder="请输入菜名" /> </el-form-item> <el-form-item label="分类" required> <el-select v-model="form.category" placeholder="请选择分类" style="width: 100%"> <el-option label="家常菜" value="家常菜" /> <el-option label="川菜" value="川菜" /> <el-option label="烘焙" value="烘焙" /> <el-option label="汤羹" value="汤羹" /> </el-select> </el-form-item> <el-form-item label="主要食材"> <el-input v-model="form.ingredients" type="textarea" :rows="3" placeholder="请输入主要食材" /> </el-form-item> <el-form-item label="制作步骤"> <el-input v-model="form.steps" type="textarea" :rows="5" placeholder="请输入制作步骤" /> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="handleSave">保存</el-button> </template> </el-dialog> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { getRecipes, createRecipe, updateRecipe, deleteRecipe } from '../api/recipe' const records = ref([]) const total = ref(0) const page = ref(1) const size = ref(10) const keyword = ref('') const loading = ref(false) const dialogVisible = ref(false) const defaultForm = { id: null, name: '', category: '', ingredients: '', steps: '', coverUrl: '' } const form = ref({ ...defaultForm }) async function loadData() { loading.value = true try { const params = { page: page.value, size: size.value } if (keyword.value) { params.keyword = keyword.value } const res = await getRecipes(params) const result = res.data records.value = result.data.records total.value = Number(result.data.total) } finally { loading.value = false } } function handleSearch() { page.value = 1 loadData() } function openDialog(row) { if (row) { form.value = { ...row } } else { form.value = { ...defaultForm } } dialogVisible.value = true } async function handleSave() { if (!form.value.name || !form.value.category) { ElMessage.warning('菜名和分类不能为空') return } if (form.value.id) { await updateRecipe(form.value) ElMessage.success('修改成功') } else { await createRecipe(form.value) ElMessage.success('新增成功') } dialogVisible.value = false loadData() } async function handleDelete(row) { await ElMessageBox.confirm(`确定删除「${row.name}」吗?`, '删除确认', { type: 'warning' }) await deleteRecipe(row.id) ElMessage.success('删除成功') loadData() } onMounted(loadData) </script>

这段代码基本代表了当前 Vue3 后台管理页面的主流写法。script setup 语法可以在组件里直接使用顶层变量,不再需要 export default;ref 用来声明响应式数据,修改 ref 的值时页面会自动更新;@click、@keyup.enter是事件绑定,对应按钮点击和输入框回车搜索。页面结构上,工具栏放搜索框和新增按钮,中间是数据表格,底部是分页组件,弹窗承担新增和编辑两个任务——通过判断 form.id 是否为空来区分当前是新增还是编辑。

运行时不需要 App.vue 做什么复杂工作,只需要确保项目入口正确加载 RecipeList 组件即可。最简单的验证方式,是把 src/App.vue 中默认模板内容替换为直接渲染这个页面,然后在浏览器地址栏访问http://localhost:5173。如果配置了 vue-router,也可以为它创建一个路由,但这不是本文的重点。

6. 联调运行与效果验证

6.1 启动后端服务

回到 Spring Boot 工程目录,在 IDEA 中直接运行启动类,或者在命令行执行 Maven 命令。

mvn spring-boot:run

正常启动后,控制台会出现 Spring Boot 的标志,并打印 Tomcat started on port(s): 8080 之类的日志。先不要急着启动前端,建议先用浏览器或者 Postman 测试一下接口。访问 http://localhost:8080/api/recipe?page=1&size=10,如果返回的 JSON 中包含 records 数组和 total 字段,说明后端服务完全正常。

6.2 启动前端服务

打开新的终端,进入 recipe-web 目录,执行:

npm run dev

Vite 启动后会提示 Local: http://localhost:5173/。打开浏览器访问这个地址,就能看到菜谱列表页面。此时测试完整的增删改查流程:

  • 点击“新增菜谱”,填写菜名、分类、主要食材和制作步骤,点击保存,表格立刻出现新记录。
  • 点击某行的“编辑”,修改菜名后保存,表格里的内容随之更新。
  • 点击某行的“删除”,确认弹窗后该行消失。
  • 在搜索框中输入“红烧”,回车后列表只剩下名称包含“红烧”的记录。

6.3 如何判断系统真的“跑通”了

很多新手分不清“页面能打开”和“系统跑通”之间的区别。这里给一个更严格的验证标准:执行一次完整的数据操作后,打开 MySQL 客户端查看 recipe 表,确认数据真实落库。比如你刚新增了一个“番茄炒蛋”,那么 SELECT * FROM recipe 中应该能看到这条记录,create_time 字段也被自动填充。同时,打开后端控制台,可以看到 MyBatis-Plus 打印出的 INSERT、UPDATE、SELECT 等 SQL 语句,每一步操作都有迹可循。

如果打开页面发现表格空白或请求报错,不要急着怀疑前端代码,先按 406 端口请求、后端接口地址、控制台报错、数据库连接状态这个顺序逐一排查。前后端联调的本质就是把“前端报错日志、后端接口返回、数据库状态”三条信息串起来对比,哪一环对不上,问题就在哪一环。

7. 常见问题与排查思路

问题现象可能原因排查方式解决方案
前端页面打不开Node 版本过低或依赖安装不全执行 node -v 查看版本,npm install 重装依赖升级 Node 至 16+,重新执行 npm install
接口 404后端上下文路径不一致检查 Controller 的 @RequestMapping 和前端 baseURL统一前缀为 /api
保存中文变乱码数据库连接 URL 缺少字符集参数查看 MySQL 表字符集与连接 URLurl 添加 characterEncoding=utf8,建库用 utf8mb4
分页始终返回全量数据未配置 MyBatis-Plus 分页插件检查是否有 MybatisPlusInterceptor 配置类按 4.4 小节添加分页拦截器
新增时报 500 错误body 参数与实体字段不匹配查看后端控制台完整异常栈对比 JSON 字段名与实体属性名,前端不要传多余必填字段
后端无法连接数据库MySQL 未启动或密码不对使用客户端测试连接,查看 datasource 配置确认 MySQL 服务状态,修改账号密码
端口被占用8080 或 5173 被其他程序占用执行 netstat -ano 查看端口修改 application.yml 或 vite.config.js 端口
SpringBoot 版本太高导致 MyBatis-Plus 不兼容Spring Boot 3 使用了不同依赖坐标查看 mvn dependency:tree使用 Boot 2.7.x,或改用 mybatis-plus-spring-boot3-starter

这里最容易被忽略的是分页插件问题。MyBatis-Plus 的 selectPage 方法如果没有拦截器支撑,分页 SQL 不会真正执行,但代码不报错,只是数据全量返回。排查时如果发现 total 值异常大,优先怀疑这里。

另一个高频问题是前后端 JSON 字段不一致。比如数据库里是 cover_url,前端写成 coverUrl,后端实体如果是驼峰命名并通过 map-underscore-to-camel-case 自动转换,那一般没问题;但如果你手动写了 ResultMap 或修改了字段名,就很容易出现所有字段为 null 的情况。出现这种问题时,打开后端控制台的 SQL 日志,对比查询字段和目标字段,很快能定位。

8. 扩展思路与面试答辩技巧

8.1 从 0 到 1 之后,如何把项目升级成亮点

CRUD 版本只是骨架。如果你想在简历里写得更充实,或者在答辩时表现出更强的能力,建议在这个基础版本上按以下顺序扩展:

第一,增加登录注册功能,使用 JWT 实现身份认证。这让系统从“谁都能删数据”变成“只有登录用户能管理自己的菜谱”,涉及 Spring Security 或 Sa-Token 等框架,一次历练能覆盖大量面试考点。第二,增加文件上传功能,为菜谱上传真实的封面图片。你可以用本地目录存储,也可以接入 MinIO 这类对象存储,顺便理解文件服务与业务服务分离的架构思想。第三,增加分类表与菜谱表的一对多关联,让分类不再是字符串字段而是一张独立表。第四,增加收藏、评论、点赞等互动功能,让系统从单机管理工具变成面向用户的社区雏形。第五,引入 ECharts 做一个分类统计仪表盘,这会让答辩演示的第一屏就抓住眼球。

扩展方向很多,但不要全做。选两三个你真正能讲清楚的功能深挖,远胜过把十个功能堆在一起却每个都答不上细节。

8.2 简历上的项目描述怎么写

简历里比较推荐这样的写法:项目名称写“美食菜谱管理系统”,技术栈写 Spring Boot + MyBatis-Plus + MySQL + Vue3 + Element Plus。项目描述不要写成流水账,而是突出你做了什么、解决了什么问题。参考句式如下:

实现菜谱信息的分页查询、关键词搜索、新增、编辑、删除等 RESTful 接口,通过 MyBatis-Plus 简化数据访问层开发,并使用统一返回结构封装接口响应。前端采用 Vue3 Composition API 与 Element Plus 搭建后台管理界面,通过 Axios 与后端交互,使用 Vite 开发代理解决本地跨域问题。实现了前后端分离模式下的完整数据流闭环,能独立部署上线。

这段描述不是纯技术名词堆砌,而是让面试官一眼看出你理解架构,也方便他顺着功能点往下追问。

8.3 面试官喜欢追问的细节

  • 为什么用 MyBatis-Plus 而不是原生 MyBatis?答:它内置单表 CRUD 方法,减少重复 SQL;但复杂查询仍可写自定义 SQL,学习时要注意两种方式的边界。
  • 前后端分离项目中,什么是跨域,为什么会出现?答:浏览器同源策略限制跨端口访问,开发时通过 Vite 代理绕开,部署时由 Nginx 反向代理统一入口。
  • 新增菜谱时,如何保证 createTime 自动填充?答:数据库 DEFAULT CURRENT_TIMESTAMP 在 insert 时自动写入,MyBatis-Plus 也会根据实体字段映射日期类型。
  • 删除操作需不需要加确认机制?答:前端用弹窗在内存中二次确认,后端接口按权限校验,生产环境建议加软删除字段代替物理删除。
  • 这个系统如何部署到服务器?答:后端打成 jar 用 java -jar 运行,前端 npm run build 生成 dist 静态文件,由 Nginx 托管并代理 /api 请求。

这些问题都不需要你背诵,只要你真的把项目从头到尾跑通过一遍,细节自然在脑子里。

9. 总结与后续学习方向

回到文章开头的问题:为什么值得用两小时搭建一个 SpringBoot+Vue3 美食菜谱管理系统?因为它是覆盖面极广的最小完整项目,从数据库建表到后端接口开发,从 Vue3 组件编写到前后端联调,再到部署思路,几乎每一环都是实际工作中的真实动作。它的价值并不在于“菜谱”这个业务本身,而在于你在搭建过程中建立起来的那条数据流转链路意识——你开始知道一条记录从表单进入浏览器、经过网络请求到达后端、再由后端落到数据库,整个过程经历了哪些变化,出现问题时又该在哪一环寻找线索。

完成这个项目之后,下一步建议按两条线推进。如果你是偏向 Java 后端的求职者,优先补充登录鉴权和文件上传,然后把接口代码重构得更加规范;如果你是偏向前端的开发者,可以深入研究 Vue Router 路由守卫、Pinia 状态管理和 Element Plus 的高级组件用法。无论走哪条线,都建议把源码整理进自己的 GitHub 或 Gitee,并写一份简洁的 README,说明项目结构、启动步骤和功能截图。这样,当你在简历里写下这个项目时,面试官点开链接就能看到真实代码,你也能自信地告诉他:这个项目从数据库到页面,每一步我都能讲清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询