从“毕业设计”和“简历项目”这两个关键词出发这个系统几乎踩中了所有经典要素Java 后端、Vue3 前端、前后端分离、MySQL 增删改查。市面上类似的模板很多但真正能跑通、能讲清楚、能经得起面试追问的版本却不多。这篇文章不准备做“源码搬运工”而是把搭建过程中的关键决策、代码结构和最容易翻车的细节拆开讲清楚。无论你是准备毕设答辩还是想在简历里加一个完整项目读完这篇可以少走很多弯路。1. 为什么“美食菜谱管理系统”会成经典项目先说结论这类系统不是技术含量最高的项目但它是“性价比最高”的练手项目。从学生视角看它覆盖了前后端分离项目最核心的链路前端页面发起请求后端接收参数、查询数据库、返回 JSON前端再把数据渲染到表格里。这条链路看似简单却正好命中企业招聘时最常问的“你独立做过什么项目”和“你怎么理解前后端交互”。把这条链路讲透比堆砌一堆从来没有运行过的“高并发秒杀”更能打动面试官。从技术视角看美食菜谱这个业务域很适合做增删改查演示。菜品有名称、分类、图片、简介、步骤等字段天然适合做列表展示、分类筛选、条件搜索和表单校验。相比传统的“学生管理系统”或“图书管理系统”菜谱主题更容易在界面设计上做出视觉效果也更容易在后期扩展出收藏、评分、模糊搜索等加分功能。从务实视角看所谓“2 小时搭建”并不是夸张但它有前提后端用 SpringBoot 快速生成基础工程前端用 Vue3 Vite 跳过繁琐的 Webpack 配置数据库表结构设计得足够简单。只要环境装好、依赖版本不冲突跑通一个最小闭环确实可以控制在两小时以内。前提是理解每一步在做什么而不是盲目复制粘贴。这篇文章默认你已经有 Java 基础会基本 Maven 操作知道 Vue 组件的大致写法。如果还不太熟建议先照着代码跑通再回头补基础。2. 系统功能与技术选型2.1 功能清单美食菜谱管理系统的核心功能不算多但足够支撑一个完整的毕业设计功能模块说明菜品管理菜品的增删改查包括名称、分类、图片、简介、步骤分类管理菜系分类川菜、粤菜、西餐等菜品与分类关联模糊搜索按菜品名称或描述关键字查询分页展示菜品列表分页显示前后端配合完成数据统计简单的分类统计或总数统计用于首页展示这些功能可以对标常见的“后台管理系统”套路。没有权限体系、没有 Redis 缓存、没有消息队列但作为基础项目反而更合适——过于复杂的功能会让答辩失去重点。2.2 技术栈说明层次技术选型说明后端框架SpringBoot快速构建 RESTful API数据库MySQL存储菜品和分类数据持久层Spring Data JPA / MyBatis-Plus二选一文章以 MyBatis-Plus 演示前端框架Vue3Composition API 组合式写法构建工具Vite开发调试效率高UI 组件Element Plus表格、表单、弹窗现成组件HTTP 请求Axios前端调用后端接口前后端联调开发代理Vite devServer 代理到 SpringBoot 端口这里必须先说明一个容易踩坑的点前后端分离不等于两个项目分开放就行。它要求前后端通过 HTTP 接口通信意味着必须解决跨域问题、接口约定问题、数据格式统一问题。实操中很多新手卡住的不是增删改查本身而是跨域、参数接收方式不一致、JSON 格式对不上。3. 环境准备与前置条件3.1 基础环境清单工具建议版本说明JDKJDK 8 或 JDK 17SpringBoot 2.x 用 JDK 8 稳妥3.x 建议 JDK 17MavenMaven 3.6后端依赖管理Node.jsNode 16.20 或 Node 18前端构建环境MySQLMySQL 5.7 或 8.x数据库IDEIntelliJ IDEA 或 VS Code后端推荐 IDEA前端 VS Code 也可以数据库工具Navicat / DataGrip / 命令行建库建表版本号这里不写死因为不同电脑环境差异很大。一个更稳妥的判断是SpringBoot 3.x 虽然新但有些旧教程和依赖不兼容做毕设求稳优先选 SpringBoot 2.7.xVue3 搭配 Vite 版本用官方默认即可。3.2 环境安装后的验证安装完成后不要急着写代码先确认环境都可用。后端对项目采用存储过程定时任2s部署的思路DATABASE 增删改查由存储过程完成前端对项目采用存储过程定时任2s部署的思路但后端对项目在验证时先使用一个简单的 REST 接口而前端则用一个基础页面验证。在命令行分别执行java -version mvn -v node -v npm -v mysql --version能看到版本输出基本就没问题。如果命令提示找不到先检查 PATH 环境变量这是新手最常见的启动失败原因。3.3 数据库准备创建一个数据库例如food_recipe字符集选择utf8mb4因为菜谱步骤里可能出现特殊字符utf8mb4比utf8兼容性更好。CREATE DATABASE IF NOT EXISTS food_recipe DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE food_recipe;然后创建分类表和菜品表。两张表之间是一对多关系。先写分类表再写菜品表。CREATE TABLE category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT 分类名称, sort INT DEFAULT 0 COMMENT 排序号 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜系分类表; CREATE TABLE recipe ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_id BIGINT NOT NULL COMMENT 分类ID, name VARCHAR(100) NOT NULL COMMENT 菜品名称, image VARCHAR(255) COMMENT 图片地址, description TEXT COMMENT 菜品简介, steps TEXT COMMENT 制作步骤, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表;如果采用 MyBatis-Plus表名可以直接用实体类映射。要注意category和recipe都不是 MySQL 保留字可以放心使用但为了稳妥建议在配置里开启驼峰映射功能。4. 后端 SpringBoot 实现4.1 创建 SpringBoot 项目后端工程结构建议按 Controller、Service、Mapper、Entity 分包清晰易懂。用 IDEA 直接创建 Spring Initializr 项目或者从 Spring 官网生成压缩包导入。核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency如果你的 SpringBoot 版本是 2.7.x数据库驱动写mysql-connector-java如果是 3.x默认是com.mysql:mysql-connector-j。这个细节经常会让人启动报错。4.2 配置文件 application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/food_recipe?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto三个容易忽略的地方第一serverTimezone必须设置。不设这个参数某些 MySQL 版本下连接会报时间错误。第二map-underscore-to-camel-case开启后数据库字段create_time才能自动映射到实体类属性createTime。第三log-impl配置成 StdOutImpl开发阶段可以在控制台看到 SQL 语句排错非常方便正式环境再关掉。4.3 实体类和 Mapper实体类直接用 Lombok 简化代码package com.example.food.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(recipe) public class Recipe { TableId(type IdType.AUTO) private Long id; private Long categoryId; private String name; private String image; private String description; private String steps; private LocalDateTime createTime; }Mapper 接口只需要继承BaseMapperpackage com.example.food.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.food.entity.Recipe; import org.apache.ibatis.annotations.Mapper; Mapper public interface RecipeMapper extends BaseMapperRecipe { }MyBatis-Plus 把单表增删改查都封装好了不需要手写 XML。这里特别说明使用 MyBatis-Plus 不等于不懂 SQL。面试官如果问你底层原理你要能说出BaseMapper是如何通过泛型推断表名和字段的以及Wrapper是如何构造条件的。4.4 Controller 和 Service后端对外提供 RESTful API界面给前端一个统一的返回结构。创建一个通用返回类Resultpackage com.example.food.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }菜品接口package com.example.food.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.food.common.Result; import com.example.food.entity.Recipe; import com.example.food.mapper.RecipeMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/recipe) public class RecipeController { Autowired private RecipeMapper recipeMapper; GetMapping public ResultPageRecipe list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String keyword) { QueryWrapperRecipe wrapper new QueryWrapper(); if (keyword ! null !keyword.isEmpty()) { wrapper.like(name, keyword).or().like(description, keyword); } wrapper.orderByDesc(create_time); PageRecipe result recipeMapper.selectPage(new Page(page, size), wrapper); return Result.success(result); } GetMapping(/{id}) public ResultRecipe detail(PathVariable Long id) { Recipe recipe recipeMapper.selectById(id); return Result.success(recipe); } PostMapping public ResultRecipe add(RequestBody Recipe recipe) { recipeMapper.insert(recipe); return Result.success(recipe); } PutMapping public ResultRecipe update(RequestBody Recipe recipe) { recipeMapper.updateById(recipe); return Result.success(recipe); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { recipeMapper.deleteById(id); return Result.success(null); } }这个 Controller 把分页查询、详情、新增、修改、删除都覆盖了。需要注意两个点第一RequestParam用来接收查询参数PathVariable用来接收路径参数RequestBody用来接收 JSON 请求体。前端传参方式的区别就在这里面试中经常被问。第二模糊搜索的or()使用要小心。like(name, keyword).or().like(description, keyword)实际生成的是name LIKE ? OR description LIKE ?如果不加外层括号后续再加其他条件时 SQL 可能会出现逻辑错误。更稳妥的写法是用and(wrapper - wrapper.like(...).or().like(...))但在这里示例已经能满足需求。Controller 直接调 Mapper 是为了演示方便。实际工程建议拆出 Service 层至少保留一个事务控制的入口后续扩展收藏、评论时不会让 Controller 越来越重。4.5 跨域配置前后端分离项目联调时浏览器会拦截跨域请求。最直接的解决方式是在后端加 CORS 配置。可以写一个配置类package com.example.food.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(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns(*)在 SpringBoot 2.7 和 3.x 里兼容性都比较好。开发阶段放开所有来源等部署上线再收紧到具体域名这是更稳妥的做法。启动后端后浏览器直接访问http://localhost:8080/api/recipe?page1size10如果能看到 JSON 数据说明后端接口已经通了。5. 前端 Vue3 实现5.1 创建 Vite 项目npm create vitelatest food-web -- --template vue cd food-web npm install项目创建完成后安装运行时依赖npm install element-plus element-plus/icons-vue axios vue-router如果网络下载慢可以配置 npm 镜像源但注意只改变包下载源不要影响代码逻辑。5.2 项目结构保持简洁src/ api/ recipe.js router/ index.js views/ RecipeList.vue RecipeEdit.vue App.vue main.js前端没有必要把目录拆得太散对毕设和简历项目来说清晰比复杂更重要。5.3 封装 axios 请求// 文件路径src/api/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request基于接口定义封装菜品请求// 文件路径src/api/recipe.js import request from ./request export function getRecipeList(params) { return request.get(/recipe, { params }) } export function addRecipe(data) { return request.post(/recipe, data) } export function updateRecipe(data) { return request.put(/recipe, data) } export function deleteRecipe(id) { return request.delete(/recipe/${id}) }这里有一个前后端协作的关键点封装的getRecipeList传的是params最后生成的是?page1size10keywordxxx而新增和修改走的是dataaxios 会序列化成 JSON 放在请求体里。正好对应后端RequestParam和RequestBody两种接收方式。如果前后端约定不一致接口就会报 400 或 415所以先想清楚参数格式再写代码。5.4 设置开发代理前端请求写的是/api/recipe但后端端口是 8080前端 Vite 默认端口是 5173直接请求会跨域。开发阶段最常见的做法是在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浏览器看起来是同源请求避免跨域问题。这种方式也比后端 CORS 更适合平时开发因为后端 CORS 配置越收越紧时前端代理不受影响。5.5 菜品列表页列表页是整套系统的核心界面包含搜索栏、表格、分页和操作按钮。!-- 文件路径src/views/RecipeList.vue -- template div classrecipe-list el-card div classsearch-bar el-input v-modelkeyword placeholder输入菜品名称或描述 clearable stylewidth: 300px keyup.enterhandleSearch / el-button typeprimary clickhandleSearch搜索/el-button el-button typesuccess clickhandleAdd新增菜品/el-button /div el-table :datatableData border stripe el-table-column propid labelID width80 / el-table-column propname label菜品名称 min-width140 / el-table-column propcategoryId label分类ID width100 / el-table-column propdescription label简介 show-overflow-tooltip min-width200 / el-table-column propcreateTime label创建时间 width180 / el-table-column label操作 width220 fixedright template #default{ row } el-button sizesmall clickhandleEdit(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 current-changeloadData / /el-card /div /template script setup import { ref, onMounted } from vue import { ElMessage, ElMessageBox } from element-plus import { getRecipeList, deleteRecipe } from ../api/recipe import { useRouter } from vue-router const router useRouter() const keyword ref() const page ref(1) const size ref(10) const total ref(0) const tableData ref([]) const loadData async () { const res await getRecipeList({ page: page.value, size: size.value, keyword: keyword.value }) tableData.value res.data.records total.value res.data.total } const handleSearch () { page.value 1 loadData() } const handleAdd () { router.push({ name: RecipeEdit }) } const handleEdit (row) { router.push({ name: RecipeEdit, query: { id: row.id } }) } const handleDelete (row) { ElMessageBox.confirm(确定删除菜品「${row.name}」吗, 提示, { confirmButtonText: 确定, cancelButtonText: 取消, type: warning }).then(async () { await deleteRecipe(row.id) ElMessage.success(删除成功) loadData() }) } onMounted(() { loadData() }) /script style scoped .search-bar { display: flex; gap: 12px; margin-bottom: 16px; } /style这段代码展示了几个 Vue3 常见的写法script setup组合式 API、模板里的v-model:current-page分页绑定、通过useRouter()跳转路由。如果你的 Vue2 基础偏强第一次接触script setup会有点不习惯但它确实是 Vue3 官方推荐的主流写法也是面试中的高频考点。5.6 编辑页新增和编辑共用一个页面通过路由参数是否存在判断是编辑还是新增。!-- 文件路径src/views/RecipeEdit.vue -- template el-card template #header span{{ form.id ? 编辑菜品 : 新增菜品 }}/span /template el-form :modelform label-width100px stylemax-width: 600px el-form-item label菜品名称 el-input v-modelform.name placeholder请输入菜品名称 / /el-form-item el-form-item label分类ID el-input-number v-modelform.categoryId :min1 :max999 / /el-form-item el-form-item label图片地址 el-input v-modelform.image placeholderhttps://... / /el-form-item el-form-item label菜品简介 el-input v-modelform.description typetextarea :rows3 / /el-form-item el-form-item label制作步骤 el-input v-modelform.steps typetextarea :rows6 / /el-form-item el-form-item el-button typeprimary clickhandleSave保存/el-button el-button clickrouterBack返回/el-button /el-form-item /el-form /el-card /template script setup import { ref, onMounted } from vue import { useRoute, useRouter } from vue-router import { ElMessage } from element-plus import { getRecipeList, addRecipe, updateRecipe } from ../api/recipe const route useRoute() const router useRouter() const form ref({ id: null, name: , categoryId: 1, image: , description: , steps: }) const loadDetail async () { const id route.query.id if (!id) return const res await getRecipeList({ page: 1, size: 1, keyword: form.value.name }) // 这里仅为演示实际请根据 id 调用详情接口 } const handleSave async () { if (!form.value.name) { ElMessage.warning(菜品名称不能为空) return } if (form.value.id) { await updateRecipe(form.value) } else { await addRecipe(form.value) } ElMessage.success(保存成功) router.push({ name: RecipeList }) } const routerBack () { router.push({ name: RecipeList }) } onMounted(() { if (route.query.id) { form.value.id Number(route.query.id) // 在实际项目中这里应调用详情接口回显数据 } }) /script必须坦诚说明这个编辑页的“数据回显”逻辑我简化了。正常项目应该新写一个/recipe/{id}详情接口在onMounted中通过detail(id)查询后把数据填进form。这里只保留了新增和保存的主流程原因是很多初学者会把回显逻辑写得过度复杂。正式项目请补全详情接口下面给出后端 Controller 中对应的detail方法。5.7 路由配置// 文件路径src/router/index.js import { createRouter, createWebHistory } from vue-router import RecipeList from ../views/RecipeList.vue import RecipeEdit from ../views/RecipeEdit.vue const routes [ { path: /, name: RecipeList, component: RecipeList }, { path: /edit, name: RecipeEdit, component: RecipeEdit } ] const router createRouter({ history: createWebHistory(), routes }) export default router完整的入口文件// 文件路径src/main.js import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(ElementPlus) app.use(router) app.mount(#app)到这里前端最基本的页面、路由、请求封装已经齐全可以开始联调。6. 运行验证与联调6.1 启动后端在 IDEA 里直接运行启动类也可以使用命令行mvn spring-boot:run观察控制台日志出现类似Started DemoApplication in xxxx seconds表示启动成功。再访问接口curl http://localhost:8080/api/recipe?page1size10如果返回 JSON 中包含records、total字段说明后端正常。如果前端返回的字段结构和后端对不上比如后端返回data.records前端却读data.list列表就一定空白。6.2 启动前端在food-web目录下执行npm run dev浏览器访问http://localhost:5173。输入一个关键字搜索查看 Network 面板中的请求路径前端请求/api/recipe?page1size10keywordxxx代理转发到http://localhost:8080/api/recipe?page1size10keywordxxx后端返回 JSON前端渲染到表格只要这四步链路能走通增删改查功能就基本完成。6.3 验证用例功能操作预期结果分页查询打开列表页表格展示第一页数据分页组件显示总条数模糊搜索输入“红烧”并回车表格只显示名称或描述包含“红烧”的菜品新增点击新增填写表单并保存列表新增一条记录修改点击编辑修改名称并保存列表对应数据更新删除点击删除确认弹窗点确定列表数据减少一条如果某一项失败优先打开浏览器的开发者工具看请求状态码。常见的状态码含义状态码含义常见原因404接口路径不存在前端请求路径或后端类上 RequestMapping 写错400请求参数格式错误前端传 JSON后端要表单参数或字段名不一致415请求体类型不支持缺少RequestBody或 Content-Type 不对500后端异常查看 IDEA 控制台异常堆栈实际开发中前后端联调 70% 的问题都出在参数接收方式和字段名不一致上。先看请求 Payload再看后端日志比盲目改代码有效得多。7. 常见问题与排查思路问题现象可能原因排查方式解决方案后端启动报数据库连接错MySQL 未启动、密码不对、url 写错检查 MySQL 服务确认 application.yml 配置修正连接配置确认数据库已创建启动报Access denied for user用户名或密码错误在 Navicat 中测试连接修改密码配置或重置数据库密码前端请求接口 404后端接口路径与前端baseURL拼接不一致查看浏览器 Network 请求完整 URL统一/api前缀检查RequestMapping返回数据正常但表格不显示后端返回结构是{code,message,data}前端读取字段不对在 axios 拦截器打印response确认res.data.records还是res.data.list新增或修改后数据没变化前端传参是 JSON后端没有RequestBody查看后端日志参数是否为空后端方法加RequestBody前端序列化格式保持 JSON时间字段显示格式不对后端 LocalDateTime 序列化格式没配置看接口返回的 JSON配置 jackson date-format 或前端做格式化处理删除后列表不刷新删除成功但没调用loadData()查看控制台是否有删除请求在删除回调后重新查询列表这组问题是从大量实际调试中提炼出来的。如果你的项目报错不在表里还有一个通用排错思路从请求入口开始一层层确认。前端看 Network后端看 Console数据库看 SQL 日志往下走总能定位到问题层。8. 最佳实践与工程建议8.1 目录与命名规范后端不要把所有类都堆在controller一个包里。建议按功能模块拆分比如recipe包里有RecipeController、RecipeService、RecipeMapper、RecipeEntity。前端页面组件用大驼峰命名方法和变量用驼峰路由路径用小写。这些细节面试官扫一眼代码就能看出来。8.2 参数校验不能只依赖前端前端表单校验只是体验后端必须做基础参数校验。比如新增菜品时菜品名称不能为空分类 ID 必须存在。可以用 Spring 的Validated注解也可以在 Controller 里手动判断。无论用哪种核心原则一致后端永远不能信任前端传过来的数据。做毕设时可能觉得没必要但面试官问“如何防止脏数据入库”时答案就在这里。8.3 接口统一返回结构所有接口统一返回Result结构好处是前端可以写统一的拦截器处理错误和加载状态。如果一会儿返回Map一会儿返回String前端就没法统一处理。这个设计看似简单但很多真实团队都做得不彻底。8.4 数据库操作注意事项删除功能在生产环境不要硬删除建议加deleted字段做逻辑删除。图片 URL 不要直接存 Base64 大文本存 URL 路径就好。表字段名避免使用 MySQL 保留字比如order、group。大批量数据操作前先备份这个项目虽然数据量不大但习惯要从平时养成。8.5 面向面试的准备把这个项目写进简历时不要写“负责前后端分离美食网站开发”这种空话。可以写成独立设计 MySQL 数据库表结构完成菜谱与分类的增删改查接口。基于 Vue3 Element Plus 实现后台管理页面封装 axios 请求并解决开发跨域问题。使用 MyBatis-Plus 简化数据访问层开发理解分页查询和条件构造器底层原理。面试官大概率会追问的问题包括跨域是怎么产生的RequestBody和RequestParam区别MyBatis-Plus 为什么不写 SQL 也能查询Vue3 的ref和reactive区别分页插件底层如何实现这些内容都能在本文中找到对应落点建议针对每个问题准备一个简短的、结合本项目场景的回答。8.6 生产部署思路本地跑通以后如果想把项目部署到服务器有两种常见路径路径一前端执行npm run build生成dist静态文件用 Nginx 部署后端打成 jar 包用java -jar运行Nginx 再把/api请求代理到后端端口。路径二如果不想用 Nginx可以直接把前端dist目录放进 SpringBoot 的src/main/resources/static下打成同一个包部署。但这会失去前后端分离的意义更适合演示和兜底不适合体现工程能力。如果想要简历项目完整度更高建议选路径一并重点写明 Nginx 配置、jar 包启动命令和进程管理方式这是很多面试官认可的工程能力。9. 总结与后续学习方向2 小时跑通这个项目真正帮助你建立的不是“我会写增删改查”而是理解一条完整的前后端协作链路数据库表如何设计、后端如何暴露接口、前端如何请求数据、数据如何通过 JSON 流动。这条链路是无数 Web 项目的骨架以后不管切换到什么业务领域骨架都是类似的。跑通之后不要停在“能运行”这一步。你可以继续做三件很有价值的事第一把分类表真正做成下拉选择替换掉现在的前端手工输入分类 ID第二补充菜品详情的独立接口和页面路由完善数据回显第三给项目加一个简单的登录页面和拦截器让后端接口只有登录后才能访问。这三件事做完项目的完整度和面试表现力会再上一个台阶。如果时间允许还可以继续研究 SpringBoot 的拦截器实现权限校验、MyBatis-Plus 的分页插件的拦截原理、Vue3 组件通信和路由守卫。这个项目虽然不大但每深入一层都能延伸到面试题库里最常出现的问题。把代码放进简历之前最好先在本地从头到尾独立操作一遍确保每一步都心里有数。能讲清楚的项目才有价值否则它只是你硬盘里一段没有灵魂的压缩包。