☰
超市商品管理系统实战:Vue+SpringBoot前后端分离开发与避坑指南
2026/10/20 8:26:17 网站建设 项目流程

简介:这是一套基于Vue+SpringBoot+MySQL的超市商品管理系统完整项目资料,面向计算机相关专业正在做毕业设计、课程设计或期末大作业的学生,以及需要Java项目实战经验的学习者。系统划分为超市区域、超市货架、商品类型、商品档案四大模块,采用用户网页端与管理后台分离的设计,实现基于角色的访问控制,权限可精确到按钮级别,并配有简单的图表分析功能,适合作为高分毕设选题或练手项目。资源以zip压缩包形式提供,整体约24.18MB,内含项目源码、数据库脚本、功能文档、开题文献外翻资料以及答辩PPT,覆盖从开发到文档撰写的完整流程。目前已有2604人学习下载,下载后无需修改即可直接运行,能帮助读者快速理解前后端分离架构、权限控制与数据库设计思路,节省搭建环境与调试的时间成本。

1. 超市商品管理系统:一套能跑通的前后端分离骨架长什么样

很多同学做课程设计或毕业设计时,一上来就纠结“用哪个框架显得高级”,结果选了冷门技术栈,环境配三天,代码写两行就卡住。超市商品管理系统这个题目本身不复杂,但它恰好覆盖了前后端分离开发里最核心的几件事:Vue 做界面、SpringBoot 写接口、MySQL 存数据。这套组合之所以成为主流,不是因为时髦,而是因为资料多、坑少、招人认。你拿到的源码、数据库脚本、功能文档、开题文献和 PPT,本质上是一套完整的交付物模板,但能不能跑起来、能不能改成自己的,取决于你是否理解每一层在干什么。这篇文章面向的是需要在一到两周内把系统跑通、并且能讲清楚技术选型理由的开发者,我会按“环境搭建 → 后端接口 → 前端联调 → 数据库设计 → 避坑排查 → 进阶技巧”的顺序,把每一步的命令、参数和常见翻车点讲透。

2. 环境搭建与项目结构:先把黑匣子拆开看

2.1 后端依赖版本怎么选才不打架

SpringBoot 的版本选择直接决定了你后面要不要花时间处理依赖冲突。我一般会锁定 SpringBoot 2.7.x 配合 JDK 8 或 JDK 11,原因很实际:JDK 8 兼容性最好,网上绝大多数教程和第三方库都默认这个版本;JDK 11 是长期支持版,如果你学校要求用新版本,选它也没问题。但千万别用 JDK 17 去配 SpringBoot 2.7 以下的版本,javax和jakarta包名变更会让你在启动时直接看到ClassNotFoundException。

MySQL 选 5.7 或 8.0 都行,但要注意驱动类名和连接 URL 的差异。5.7 用com.mysql.jdbc.Driver,8.0 用com.mysql.cj.jdbc.Driver,而且 8.0 必须加时区参数,否则连接会被拒绝。Maven 用 3.6 以上,IDEA 或 Eclipse 都行,但 IDEA 的 Maven 自动导入有时候会抽风,建议在命令行先跑一次mvn clean compile确认依赖能拉下来。

# 检查 JDK 版本,确保是 1.8 或 11 java -version # 检查 Maven 版本 mvn -v # 在项目根目录执行编译,看依赖是否完整 mvn clean compile -DskipTests

这三条命令的作用分别是:确认 Java 环境、确认构建工具、验证依赖树。如果mvn compile报错说找不到某个包,大概率是pom.xml里的版本号写死了但仓库里没有,这时候把版本号改成RELEASE或者去 Maven 中央仓库查一下实际存在的版本。参数-DskipTests是跳过测试,因为很多课程项目的测试用例根本没写完,不跳过会卡在测试阶段。

2.2 前端 Vue 项目的初始化与代理配置

Vue 项目常见的是 Vue 2 + Element UI 或者 Vue 3 + Element Plus。如果你拿到的源码是 Vue 2,就用npm install装依赖;如果是 Vue 3,用npm install也行,但要注意node_modules里可能有版本冲突。我一般会先看package.json里的vue版本号,然后决定用npm还是yarn。国内网络环境下,建议把 npm 源切到国内镜像,否则装依赖能等到怀疑人生。

# 查看当前 npm 源 npm config get registry # 切换为国内镜像源(这里用通用镜像地址,不涉及任何特定品牌) npm config set registry https://registry.npmmirror.com # 安装依赖 npm install # 启动开发服务器 npm run serve

npm run serve启动后,默认端口是 8080。但后端 SpringBoot 也默认跑 8080,两个会冲突。解决办法有两个:要么改后端端口为 8081,要么改前端端口为 8081。我习惯改后端,因为前端代理配置里写后端地址更方便。在vue.config.js里加一段代理:

module.exports = { devServer: { port: 8081, // 前端跑 8081 proxy: { '/api': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, pathRewrite: { '^/api': '' } // 去掉 /api 前缀 } } } }

这段配置的意思是:前端所有以/api开头的请求,都会被转发到http://localhost:8080,并且把/api前缀去掉。比如前端请求/api/goods/list,实际打到后端的是/goods/list。changeOrigin设为true是为了让后端收到的请求头里的 Host 是后端地址,避免跨域拦截。如果你不改端口,两个 8080 撞在一起,前端启动会直接报端口占用,这时候要么杀进程要么改配置,没有第三条路。

2.3 数据库脚本导入与字符集设置

数据库脚本一般是一个.sql文件,里面包含建库、建表、插入初始数据。导入之前先确认 MySQL 服务在跑,然后用命令行或者 Navicat 执行。我强烈建议用命令行,因为 Navicat 有时候会静默失败,你根本不知道哪张表没建成功。

# 登录 MySQL mysql -u root -p # 创建数据库并指定字符集 CREATE DATABASE supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 切换数据库 USE supermarket; # 导入 SQL 文件(在 MySQL 命令行外执行) mysql -u root -p supermarket < /path/to/supermarket.sql

字符集一定要用utf8mb4,不要用utf8。MySQL 里的utf8是残缺的,存不了 emoji 和某些生僻字,商品名称里如果有特殊符号就会报错。utf8mb4_general_ci是排序规则,ci表示不区分大小写,对商品搜索场景够用了。导入完成后,用SHOW TABLES;确认表都建好了,再用SELECT COUNT(*) FROM goods;看看初始数据有没有进去。如果表是空的,说明 SQL 文件里的INSERT语句没执行,可能是文件编码问题,用file -i supermarket.sql看一下文件编码,如果是iso-8859-1就转成utf-8再导入。

3. 后端接口开发:从实体类到 RESTful 接口

3.1 实体类与数据库表的映射关系

SpringBoot 里用 JPA 或者 MyBatis 都行,但课程项目里 MyBatis-Plus 更常见,因为它省去了大量 XML 配置。不管用哪种,实体类的字段必须和数据库表字段一一对应。比如商品表goods有id、name、price、stock、category_id这几个字段,实体类里就要有对应的属性,并且用注解标明主键和自增策略。

import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; @TableName("goods") // 指定对应表名 public class Goods { @TableId(type = IdType.AUTO) // 主键自增 private Long id; private String name; private Double price; private Integer stock; private Long categoryId; // 省略 getter 和 setter }

@TableName注解告诉 MyBatis-Plus 这个类对应哪张表,如果类名和表名一致可以省略。@TableId里的IdType.AUTO表示主键由数据库自增,插入时不需要手动赋值。这里有个坑:字段名categoryId在数据库里是category_id,MyBatis-Plus 默认开启驼峰转下划线,所以不用额外配置。但如果你用的是原生 MyBatis,就必须在resultMap里手动映射,否则查出来的categoryId永远是null。

3.2 商品增删改查接口的四个关键参数

RESTful 风格的接口设计里,增删改查分别对应 POST、DELETE、PUT、GET。但课程项目里为了省事,很多人把删除做成 GET 请求,这是不对的,因为 GET 请求会被浏览器缓存,而且语义上不安全。我一般会严格按规范来,下面以商品接口为例。

@RestController @RequestMapping("/goods") public class GoodsController { @Autowired private GoodsService goodsService; // 分页查询,page 和 size 是必传参数 @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { return Result.success(goodsService.pageQuery(page, size, keyword)); } // 新增商品,请求体是 JSON @PostMapping("/add") public Result add(@RequestBody Goods goods) { return Result.success(goodsService.save(goods)); } // 修改商品,id 从路径取 @PutMapping("/update/{id}") public Result update(@PathVariable Long id, @RequestBody Goods goods) { goods.setId(id); return Result.success(goodsService.updateById(goods)); } // 删除商品,id 从路径取 @DeleteMapping("/delete/{id}") public Result delete(@PathVariable Long id) { return Result.success(goodsService.removeById(id)); } }

@RequestParam里的defaultValue是默认值,page不传就是第 1 页,size不传就是每页 10 条。keyword设为required = false,表示可以不传,不传就查全部。@PathVariable是从 URL 路径里取参数,比如/goods/delete/5里的5就是 id。@RequestBody是把请求体里的 JSON 转成对象,前端必须用Content-Type: application/json发送,否则会报 415 错误。这里最容易翻车的地方是日期字段,如果商品有create_time,前端传字符串后端要用@JsonFormat注解格式化,否则会报类型转换异常。

3.3 统一返回结果与全局异常处理

接口返回格式不统一,前端处理起来会非常痛苦。我一般会定义一个Result类,包含code、msg、data三个字段,然后用@ControllerAdvice做全局异常捕获。

public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "操作成功"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } }

全局异常处理类用@RestControllerAdvice注解,捕获Exception后返回Result.error()。这样前端永远只需要判断code是不是 200,不用去猜这次返回的是数组还是对象。参数说明:code用 200 表示成功,500 表示业务异常,401 表示未登录,403 表示无权限。这套编码规则不是强制的,但团队里统一了就别乱改,否则前端会疯。

4. 前端页面与接口联调:把数据从数据库搬到浏览器

4.1 商品列表页的分页与搜索实现

前端列表页的核心是表格组件和分页组件。Element UI 的el-table和el-pagination配合起来很顺手,但要注意分页参数和后端接口的对应关系。后端接口是/goods/list?page=1&size=10&keyword=xxx,前端就要把当前页码、每页条数、搜索关键词拼成对象传过去。

export default { data() { return { tableData: [], total: 0, query: { page: 1, size: 10, keyword: '' } } }, methods: { fetchData() { this.$axios.get('/api/goods/list', { params: this.query }) .then(res => { if (res.data.code === 200) { this.tableData = res.data.data.records; this.total = res.data.data.total; } }) .catch(err => { this.$message.error('查询失败:' + err.message); }); }, handleSearch() { this.query.page = 1; // 搜索时重置到第一页 this.fetchData(); }, handlePageChange(page) { this.query.page = page; this.fetchData(); } }, mounted() { this.fetchData(); } }

params是 axios 的查询参数配置,会自动拼成 URL 后面的?page=1&size=10。res.data.data.records是 MyBatis-Plus 分页对象里的数据列表,total是总条数。搜索时一定要把page重置为 1,否则你在第 5 页搜索,结果只有 2 条数据,页面会显示空白,用户以为系统坏了。这个细节很多教程不讲,但实际用的时候一定会遇到。

4.2 新增与编辑弹窗的表单校验

新增和编辑通常共用一个弹窗组件,区别在于编辑时表单里有初始值。表单校验用el-form的rules属性,但要注意校验触发时机。我一般用blur触发,不用change,因为change在输入过程中就会触发,用户体验很烦躁。

rules: { name: [ { required: true, message: '商品名称不能为空', trigger: 'blur' }, { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' } ], price: [ { required: true, message: '价格不能为空', trigger: 'blur' }, { pattern: /^\d+(\.\d{1,2})?$/, message: '价格格式不正确', trigger: 'blur' } ], stock: [ { required: true, message: '库存不能为空', trigger: 'blur' }, { type: 'number', message: '库存必须为数字', trigger: 'blur' } ] }

pattern是正则校验,/^\d+(\.\d{1,2})?$/表示整数或最多两位小数。type: 'number'是 Element UI 内置的类型校验,但要注意如果输入框绑定的值是字符串,这个校验会失效,所以要在提交前用Number()转一下。提交时先调this.$refs.form.validate(),返回true才发请求,否则直接return。

4.3 跨域问题的三种解法与选择

跨域是前后端分离项目里绕不开的坎。浏览器同源策略规定,协议、域名、端口三者有一个不同就算跨域。前端跑 8081,后端跑 8080,端口不同,必然跨域。解法有三种:后端加@CrossOrigin注解、前端配代理、Nginx 反向代理。开发阶段我推荐前端配代理,因为改起来最快,不用重启后端。

// 后端全局跨域配置(备用方案) @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }

allowedOriginPatterns("*")是 SpringBoot 2.4 以后的新写法,老版本用allowedOrigins("*"),但后者在allowCredentials(true)时会报错。maxAge(3600)表示预检请求的结果缓存一小时,减少 OPTIONS 请求次数。如果前端已经配了代理,这段代码可以不加,加了反而多一层开销。生产环境不要用*,要写具体域名,否则等于没做安全限制。

5. 避坑与排查:那些让你熬夜的报错

5.1 启动报错Table 'xxx' doesn't exist

现象:SpringBoot 启动时控制台抛出SQLSyntaxErrorException,提示某张表不存在。原因通常是数据库名写错了,或者application.yml里的url没指定数据库。解决:检查spring.datasource.url是不是jdbc:mysql://localhost:3306/supermarket,最后的supermarket必须和实际数据库名一致。另外 MyBatis-Plus 默认把类名转成下划线表名,如果实体类叫Goods,表名必须是goods,不能是Goods。

5.2 前端请求返回 404 但后端日志没输出

现象:浏览器 Network 里看到 404,但后端控制台没有任何请求日志。原因大概率是代理没生效,请求根本没打到后端。解决:检查vue.config.js里的target地址是不是http://localhost:8080,注意是http不是https,而且不要加/api后缀。改完vue.config.js必须重启前端服务,热更新不会重新加载代理配置。

5.3 分页查询返回的total永远是 0

现象:列表能显示数据,但分页组件显示总条数为 0,翻页也翻不动。原因是没有配置 MyBatis-Plus 的分页插件。解决:在配置类里加一个PaginationInnerInterceptor的 Bean。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

DbType.MYSQL指定数据库类型,不写的话分页 SQL 可能拼错。这个插件不加,page方法返回的total就是 0,因为 MyBatis-Plus 根本没执行 count 查询。

5.4 中文乱码从数据库一路乱到浏览器

现象:商品名称在数据库里看是正常的,但前端页面显示问号或方块。原因通常是连接 URL 没加字符集参数。解决:在application.yml的url后面加上?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai。useUnicode和characterEncoding管的是传输编码,serverTimezone管的是时区,不设时区 MySQL 8.0 会直接拒绝连接。三个参数缺一不可。

5.5 修改商品后列表没刷新

现象:编辑弹窗里点了保存,提示成功,但表格里的数据还是旧的。原因是保存成功后没有重新调fetchData()。解决:在update请求的then回调里加一行this.fetchData(),并且关闭弹窗。如果用了keep-alive缓存页面,还要在activated钩子里也调一次,否则从其他页面切回来还是旧数据。

6. 进阶技巧:让这套系统从能跑到能看

6.1 用拦截器做登录校验与 Token 续期

课程项目里登录功能通常很简单,但如果你想让系统看起来更完整,可以加一个 JWT 拦截器。用户登录成功后,后端生成一个 Token 返回给前端,前端存在localStorage里,每次请求通过请求头Authorization带上。后端拦截器校验 Token 是否有效,无效就返回 401。

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { response.setStatus(401); return false; } return true; } }

preHandle返回false表示拦截,请求不会进入 Controller。JwtUtil.verify是自己写的工具类,校验签名和过期时间。Token 有效期一般设 2 小时,快过期时前端可以调一个刷新接口换新 Token,避免用户操作到一半被踢出去。这个功能不是必须的,但加上之后答辩时能多讲五分钟。

6.2 用 ECharts 给商品数据加一个可视化看板

商品管理系统如果只有表格,看起来会很单调。加一个首页看板,用 ECharts 画饼图和柱状图,展示各分类商品占比和库存预警。数据从后端/goods/statistics接口拿,返回格式是{ categories: ['饮料', '零食'], counts: [12, 8] }。

import * as echarts from 'echarts'; export default { mounted() { this.$axios.get('/api/goods/statistics').then(res => { const chart = echarts.init(this.$refs.chart); chart.setOption({ tooltip: { trigger: 'item' }, series: [{ type: 'pie', radius: '60%', data: res.data.data.categories.map((name, i) => ({ name: name, value: res.data.data.counts[i] })) }] }); }); } }

echarts.init需要传一个 DOM 容器,用ref获取。radius: '60%'控制饼图大小,太大或太小都不好看。数据映射用map把两个数组拼成 ECharts 需要的{name, value}格式。注意图表容器必须有明确的宽高,否则图表渲染不出来,这是 ECharts 最常见的坑。

6.3 数据库备份与 SQL 导出的小习惯

最后说一个我自己的习惯:每次改完数据库结构,立刻导出一份 SQL 备份。命令很简单,但能救命。

mysqldump -u root -p supermarket > backup_$(date +%Y%m%d).sql

$(date +%Y%m%d)会生成当天日期作为文件名,比如backup_20250401.sql。这样你改坏了表结构,直接mysql -u root -p supermarket < backup_20250401.sql就能回滚。我见过太多人因为没备份,把DROP TABLE敲下去之后只能重头再来。这个习惯不花时间,但能让你少熬几个通宵。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询