ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3美食菜谱管理系统:前后端分离实战教程

SpringBoot+Vue3美食菜谱管理系统:前后端分离实战教程 正在开发一个管理系统类项目时你在招聘网站上投出去的第一批简历十有八九会卡在“项目经验”这一栏。很多应届生和转行者都会在某个深夜冒出同一个念头该做一个什么样的项目既能满足毕业设计的要求又能让面试官愿意多问两句如果你搜索过这个问题的答案大概率会看到一个组合词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 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这段配置的意义有三层。url 里的useUnicodetruecharacterEncodingutf8是解决中文乱码的关键serverTimezoneAsia/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 BaseMapperRecipe { }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 IPageRecipe page(int page, int size, String keyword) { LambdaQueryWrapperRecipe 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 ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT 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 ResultIPageRecipe 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 ResultRecipe detail(PathVariable Long id) { return Result.ok(recipeService.getById(id)); } PostMapping public ResultVoid create(RequestBody Recipe recipe) { recipeService.create(recipe); return Result.ok(null); } PutMapping public ResultVoid update(RequestBody Recipe recipe) { recipeService.update(recipe); return Result.ok(null); } DeleteMapping(/{id}) public ResultVoid 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 vitelatest recipe-web -- --template vue cd recipe-web npm install npm install axios element-plus如果执行npm create vitelatest时提示交互选择直接选择 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 classrecipe-page div classtoolbar el-input v-modelkeyword placeholder输入菜名搜索 clearable stylewidth: 260px keyup.enterhandleSearch / el-button typeprimary clickhandleSearch搜索/el-button el-button typesuccess clickopenDialog()新增菜谱/el-button /div el-table :datarecords border stripe v-loadingloading el-table-column propid labelID width70 / el-table-column propname label菜名 min-width140 / el-table-column propcategory label分类 width110 / el-table-column propingredients label主要食材 min-width200 show-overflow-tooltip / el-table-column propsteps label制作步骤 min-width200 show-overflow-tooltip / el-table-column label操作 width180 fixedright template #default{ row } el-button sizesmall clickopenDialog(row)编辑/el-button el-button sizesmall typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagepage v-model:page-sizesize :totaltotal layouttotal, prev, pager, next, sizes :page-sizes[5, 10, 20] current-changeloadData size-changeloadData stylemargin-top: 16px; justify-content: flex-end / el-dialog v-modeldialogVisible :titleform.id ? 编辑菜谱 : 新增菜谱 width560px el-form :modelform label-width90px el-form-item label菜名 required el-input v-modelform.name placeholder请输入菜名 / /el-form-item el-form-item label分类 required el-select v-modelform.category placeholder请选择分类 stylewidth: 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-modelform.ingredients typetextarea :rows3 placeholder请输入主要食材 / /el-form-item el-form-item label制作步骤 el-input v-modelform.steps typetextarea :rows5 placeholder请输入制作步骤 / /el-form-item /el-form template #footer el-button clickdialogVisible false取消/el-button el-button typeprimary clickhandleSave保存/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 defaultref 用来声明响应式数据修改 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?page1size10如果返回的 JSON 中包含 records 数组和 total 字段说明后端服务完全正常。6.2 启动前端服务打开新的终端进入 recipe-web 目录执行npm run devVite 启动后会提示 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 添加 characterEncodingutf8建库用 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. 总结与后续学习方向回到文章开头的问题为什么值得用两小时搭建一个 SpringBootVue3 美食菜谱管理系统因为它是覆盖面极广的最小完整项目从数据库建表到后端接口开发从 Vue3 组件编写到前后端联调再到部署思路几乎每一环都是实际工作中的真实动作。它的价值并不在于“菜谱”这个业务本身而在于你在搭建过程中建立起来的那条数据流转链路意识——你开始知道一条记录从表单进入浏览器、经过网络请求到达后端、再由后端落到数据库整个过程经历了哪些变化出现问题时又该在哪一环寻找线索。完成这个项目之后下一步建议按两条线推进。如果你是偏向 Java 后端的求职者优先补充登录鉴权和文件上传然后把接口代码重构得更加规范如果你是偏向前端的开发者可以深入研究 Vue Router 路由守卫、Pinia 状态管理和 Element Plus 的高级组件用法。无论走哪条线都建议把源码整理进自己的 GitHub 或 Gitee并写一份简洁的 README说明项目结构、启动步骤和功能截图。这样当你在简历里写下这个项目时面试官点开链接就能看到真实代码你也能自信地告诉他这个项目从数据库到页面每一步我都能讲清楚。
返回列表