每年这个时间点,我的消息列表里就会被同一类问题塞满:“老师,商城项目启动报错了”、“SpringBoot到底用2.x还是3.x啊”、“Vue3的响应式怎么跟Vue2完全不一样”。做毕业设计选在线商城系统的人特别多,但真正能把一个商城系统从数据库设计到前后端联调完整跑通的人,少之又少。不是大家不努力,而是网上能找到的资料版本太旧、代码残缺、数据库脚本对不上,照着敲都跑不起来。
这套SpringBoot3 + Vue3在线商城系统,定位就是给零基础学生做毕业设计用的完整解决方案。它不是一个只有几个页面拼起来的“玩具项目”,而是包含用户、商品、购物车、订单、分类、搜索这类完整电商闭环的实战项目。你拿到的不是一堆零散代码,而是一整套包含教程视频、可运行源码、数据库初始化脚本、部署文档和答疑服务的材料包。我会在这篇博文里把这个项目的核心设计思路、数据库怎么建模、前后端怎么通信、启动会遇到哪些坑一次讲透,让你拿到手之后不是照葫芦画瓢,而是真的能看懂每一步在干什么。
1. 为什么选择SpringBoot3 + Vue3这套组合
1.1 技术选型背后的现实考量
很多同学在选题的时候会纠结一个问题:网上商城系统教程这么多,有SSM的、有JSP的、有纯前端静态页面的,为什么我偏偏推荐SpringBoot3 + Vue3?
先说后端。SpringBoot3是2022年底发布的里程碑式版本,底层基于Spring Framework 6和JDK 17构建,整体框架全面拥抱Jakarta EE规范。相比你学长学姐用的SpringBoot2.x,它最直观的变化是javax.*包名全部换成了jakarta.*,这就意味着大量老教程里的代码直接复制过来是跑不起来的——这恰恰是你的机会。答辩的时候老师问你“为什么用SpringBoot3”,你可以明确回答:它内置了GraalVM原生镜像支持、AOT编译优化,启动速度更快、内存占用更低,这些特性足以体现你对新技术栈的敏感度。
再说前端。Vue3目前已经是绝对的主流版本,Composition API配合<script setup>语法糖让组件逻辑复用变得异常轻快。而且Vue3的生态已经非常成熟,Element Plus、Vite、Pinia这些配套工具链稳定得让人感动。你要是现在还去学Vue2,等于在2024年买了一台只支持2G网络的手机——能用,但没前途。
1.2 前后端分离架构的底层逻辑
SpringBoot3 + Vue3天然就是一套前后端分离的架构方案。前端用Vue3跑在Node环境下的开发服务器,通过HTTP请求调用后端接口;后端用SpringBoot3提供RESTful API,返回JSON数据。两者之间通过接口文档约定字段,互不干扰。
这种架构的好处不只是“看起来高大上”,而是实打实地贴近企业级开发流程。前端工程师和后端工程师可以并行开发,只要提前约定好接口格式就行。你的毕业设计采用这种架构,意味着你在简历上写的“熟悉前后端分离开发模式”这句话是有实际项目支撑的,不是空话。而且前端项目可以通过Vite直接打包成静态文件,扔到Nginx里就能跑;后端项目打包成Jar包,用java -jar命令就能启动,部署逻辑非常清晰。
1.3 相比传统方案的答辩优势
如果用JSP + Servlet做商城,你写出来的页面是后端动态渲染的,前端逻辑和后端代码强耦合,改个按钮样式都要重启Tomcat。现在用Vue3,页面渲染全部在浏览器端完成,后端只负责提供数据接口,这种解耦设计在系统架构层面就已经领先了。
另一个隐藏的加分点是RESTful API设计。你的后端接口会按照GET /api/products、POST /api/orders这样的规范来组织,这种设计风格本身就是当前主流Web服务的标准做法。答辩时老师随便问你一个接口设计思路,你都能从资源定位、HTTP方法语义、状态码含义几个维度展开,这就是实打实的专业度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 在线商城系统的功能设计与数据库建模
2.1 核心业务模块拆解
动手写代码之前,第一件要做的事是理清楚在线商城系统到底有哪些核心模块。不是功能堆得越多越好,而是要覆盖电商业务的主链路,同时控制开发量在一个毕业生能完成的范围内。我设计的这套系统主要包含以下六大模块:
- 用户模块:注册、登录、个人信息查看与修改,密码加密存储
- 商品模块:商品列表展示(分页)、商品详情、按分类筛选、关键词搜索
- 购物车模块:加入购物车、修改数量、删除商品、清空购物车
- 订单模块:创建订单(从购物车结算)、订单列表、订单详情、取消订单
- 分类模块:一级分类管理,用于商品的归类展示
- 轮播图模块:首页轮播图管理,运营位展示
每个模块的功能设计都遵循“够用但不过度”的原则。比如支付功能,很多同学想接支付宝沙箱,但是涉及商户号申请、回调验签、证书配置这些复杂流程,很容易卡住。我的建议是毕业设计阶段用“模拟支付”代替,创建订单后直接标记为已支付,把精力留给核心业务逻辑。
2.2 数据库表结构设计实操
数据库设计是整套系统的地基,地基不稳后面全塌。我用MySQL 8.0作为示例,核心数据表一共6张,每张表的字段设计都经过实际业务验证,你可以直接抄作业。
先看用户表t_user:
sql复制CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(255) NOT NULL COMMENT '密码(BCrypt加密)',
`nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像地址',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
注意几个细节。密码字段长度设成255而不是20,是因为BCrypt加密后的字符串长度是60位,有些人用MD5只存32位就够了,但BCrypt更安全,字段长度必须留够。用户名加唯一索引uk_username,防止重复注册。create_time和update_time用datetime类型并设置默认值,这样插入数据的时候不用手动维护时间字段,省心。
再看商品表t_product:
sql复制CREATE TABLE `t_product` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`name` varchar(200) NOT NULL COMMENT '商品名称',
`sub_title` varchar(255) DEFAULT NULL COMMENT '商品副标题',
`main_image` varchar(255) DEFAULT NULL COMMENT '主图地址',
`detail` text COMMENT '商品详情',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`stock` int(11) NOT NULL DEFAULT 0 COMMENT '库存',
`category_id` bigint(20) DEFAULT NULL COMMENT '分类ID',
`status` tinyint(4) DEFAULT 1 COMMENT '状态:1上架,0下架',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
价格为什么用decimal(10,2)而不是double?因为浮点数在计算金额时有精度丢失的问题,比如0.1 + 0.2结果可能是0.30000000000000004,这在电商业务里是不可接受的。decimal是精确数值类型,10表示总位数,2表示小数位数,足够覆盖万元级别的商品价格。库存字段用int类型就够了,但要注意在后端逻辑里做库存校验,防止超卖。
订单表t_order和订单明细表t_order_item是典型的主从表结构:
sql复制CREATE TABLE `t_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单编号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额',
`status` tinyint(4) DEFAULT 0 COMMENT '状态:0待支付,1已支付,2已取消',
`receiver_name` varchar(50) DEFAULT NULL COMMENT '收货人',
`receiver_phone` varchar(20) DEFAULT NULL COMMENT '收货电话',
`receiver_address` varchar(255) DEFAULT NULL COMMENT '收货地址',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
CREATE TABLE `t_order_item` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` bigint(20) NOT NULL COMMENT '订单ID',
`product_id` bigint(20) NOT NULL COMMENT '商品ID',
`product_name` varchar(200) DEFAULT NULL COMMENT '商品名称快照',
`product_image` varchar(255) DEFAULT NULL COMMENT '商品图片快照',
`price` decimal(10,2) NOT NULL COMMENT '成交单价',
`quantity` int(11) NOT NULL COMMENT '购买数量',
`total_price` decimal(10,2) NOT NULL COMMENT '小计金额',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
订单明细表里为什么要冗余product_name和product_image字段?因为商品名称和图片是会被修改的,如果订单明细通过商品ID去关联查询实时数据,那么历史订单里的商品信息就可能变得面目全非。把关键信息做快照存储,才能保证订单的历史可追溯性。这是电商系统设计里非常经典的一个经验点,面试和答辩都爱问。
2.3 外键、索引与字段设计的策略
我在表结构里故意没用物理外键约束,只加了普通索引。这不是偷懒,而是行业共识。物理外键会导致插入、更新数据时MySQL必须额外做一致性检查,在高并发场景下会成为明显的性能瓶颈。而且一旦数据量上来,删除父表记录会引发连锁的外键约束检查,管理起来非常痛苦。用逻辑外键(即字段上建立索引),通过应用层代码保证数据一致性,是互联网大厂的主流做法。
索引方面,我建议遵循两个原则:第一,所有查询条件的WHERE字段都要考虑加索引,比如商品表的category_id、订单表的user_id、订单明细表的order_id;第二,索引不是越多越好,每个索引都会增加写入成本和存储空间,字段区分度低的列(比如status这种只有0和1的)不适合单独建索引。
数据库这块我多说一句。很多同学拿到SQL脚本直接导入就完事了,我建议你花半小时把每张表的字段含义理一遍,最好能自己从零建一遍表。因为答辩的时候老师特别喜欢问“你这个订单状态是怎么设计的”“这个字段为什么用decimal”,你能答上来,就已经超出80%的毕业生了。
3. SpringBoot3后端从0到1搭建
3.1 项目初始化与环境准备
后端开发环境建议直接用IDEA,社区版就够用。前提条件是一台安装好JDK 17的电脑,注意不是JDK 8也不是JDK 11,SpringBoot3必须JDK 17及以上才能跑。IDEA里新建项目时选择Spring Initializr,Server URL直接用默认的start.spring.io,如果网络不稳定可以换成阿里云的镜像地址。
依赖选择是第一个坑。SpringBoot3的起步依赖和2.x基本一致,但groupId已经变成了jakarta.*。在pom.xml里你需要引入以下核心依赖:
xml复制<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>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
ORM框架我选的是MyBatis-Plus而不是原生MyBatis。原因很简单:单表CRUD不需要手写XML,BaseMapper已经把selectById、insert、deleteById这些常用方法封装好了,你只需要写复杂的多表关联查询SQL。生成环境代码量直接砍掉一半,对毕设项目来说效率翻倍。
3.2 分层架构与统一响应体设计
后端代码的包结构我按照标准的Controller层、Service层、Mapper层三层架构来组织:
code复制com.example.mall
├── MallApplication.java
├── controller // 接口层,接收请求
├── service // 业务逻辑层
├── mapper // 数据访问层
├── entity // 实体类
├── dto // 入参出参对象
├── common // 公共类(响应体、异常处理、工具类)
└── config // 配置类(跨域、拦截器、MyBatis-Plus配置)
每层各司其职,Controller只负责参数接收和结果返回,Service封装业务逻辑,Mapper操作数据库。这种分层清晰的项目结构,答辩时老师一眼就能看出你的工程素养。
统一响应体是我强烈建议你做的设计。没有统一响应体的话,每个接口返回格式都不一样,前端对接会疯掉。我项目里的响应体定义如下:
java复制@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(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;
}
}
前端拿到响应后只需要判断code是不是200,是则渲染data,否则弹出message提示。这种模式在后端接口开发中被广泛使用,你在简历的项目经验里写上“设计了统一响应体规范”,HR看了也会觉得你懂工程化实践。
3.3 用户登录与JWT鉴权实现
用户模块是所有系统的地基。密码加密我用的BCrypt算法,它会自动生成随机盐,相同密码每次加密结果都不一样,比MD5那种可逆的老古董安全得多。注册时的核心逻辑只需一行:userMapper.insert(user),但密码入库前必须加密:
java复制public void register(RegisterDTO dto) {
// 检查用户名是否已存在
Long count = userMapper.selectCount(
new LambdaQueryWrapper<User>()
.eq(User::getUsername, dto.getUsername())
);
if (count > 0) {
throw new BusinessException("用户名已存在");
}
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setNickname(dto.getNickname());
userMapper.insert(user);
}
登录成功之后如何保持用户状态?我用的方案是JWT(JSON Web Token)。JWT本质上是一段经过签名的JSON字符串,包含用户ID和过期时间,服务端不存储session,无状态的设计让它非常适合前后端分离场景。用户登录成功后后端签发一个token返回给前端,前端存在localStorage里,之后每次请求都在请求头带上Authorization: Bearer <token>,后端通过拦截器解析token获取当前用户身份。
java复制public String generateToken(Long userId, String username) {
long now = System.currentTimeMillis();
Date expiryDate = new Date(now + 24 * 60 * 60 * 1000); // 24小时过期
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(new Date(now))
.setExpiration(expiryDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
这套登录逻辑是整个后端最核心的亮点之一,建议你把它彻底吃透。登录接口返回的是用户基本信息和token,之后所有需要登录的接口(如购物车、订单)都会校验token。
3.4 商品查询与购物车接口实现
商品模块的接口相对简单,大部分是查询操作。商品分页接口用MyBatis-Plus的分页插件,配置一个PaginationInnerInterceptor就能实现物理分页:
java复制public Page<ProductVO> getProductPage(ProductQueryDTO query) {
Page<Product> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(query.getKeyword())) {
wrapper.like(Product::getName, query.getKeyword());
}
if (query.getCategoryId() != null) {
wrapper.eq(Product::getCategoryId, query.getCategoryId());
}
wrapper.eq(Product::getStatus, 1); // 只查询上架商品
wrapper.orderByDesc(Product::getCreateTime);
productMapper.selectPage(page, wrapper);
// 把实体转成VO对象返回,避免把敏感字段返回给前端
}
购物车模块我建议在后端数据库存一份,而不是像某些教程一样只用前端的localStorage。原因很简单:数据库存的购物车才能做到多端同步,并且可以把购物车商品和库存做联动校验。购物车的表结构在2.2节没有展开,这里补充一下:
sql复制CREATE TABLE `t_cart` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`product_id` bigint(20) NOT NULL COMMENT '商品ID',
`quantity` int(11) NOT NULL DEFAULT 1 COMMENT '数量',
`checked` tinyint(1) DEFAULT 1 COMMENT '是否选中',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_product` (`user_id`, `product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';
注意uk_user_product联合唯一索引,同一个用户同一个商品只能有一条购物车记录,如果用户已经加过某个商品再加入,只是数量累加而不是新增记录。这个设计很细但很关键。
3.5 订单流程与事务处理
订单模块是业务链路里最复杂的一环,它的核心逻辑是:从购物车勾选商品生成订单,扣减库存,清空购物车,这三步必须同时成功或同时失败,绝对不能出现“订单创建了但库存没扣”的情况。这就用到了Spring的@Transactional事务注解:
java复制@Transactional(rollbackFor = Exception.class)
public OrderVO createOrder(OrderCreateDTO dto) {
// 1. 查询用户勾选的购物车项
// 2. 循环计算总价,同时检查库存是否充足
// 3. 生成订单主记录(订单编号用UUID + 时间戳)
// 4. 生成订单明细列表
// 5. 扣减库存(UPDATE t_product SET stock = stock - ? WHERE id = ? AND stock >= ?)
// 6. 删除对应的购物车记录
// 7. 返回订单详情
return orderVO;
}
扣减库存那段SQL我用了原子操作stock = stock - ?并且加了一个stock >= ?的条件判断,这是为了防止并发超卖的关键。如果库存不足,更新影响行数为0,直接抛出异常回滚事务。这种写法比“先查询库存再判断”更安全,因为查询和更新之间的时间窗口里,库存可能已经被其他请求改掉了。
订单编号的生成也值得说说。直接用自增ID当订单号会暴露系统订单量,而且容易撞号。我用的方案是yyyyMMddHHmmss + 三位随机数 + 用户ID后四位,这样既能保证唯一性,又有可读性。当然如果要求更严谨可以引入雪花算法,但对毕业设计来说上面这个方案已经足够。
4. Vue3前端从0到1搭建
4.1 Vite创建项目与工程结构
前端的技术栈是Vue3 + Vite + Vue Router + Pinia + Element Plus,全都是目前Vue生态的主流选择。创建项目的命令就一条:
bash复制npm create vite@latest mall-web -- --template vue
Vite是Vue3官方推荐的构建工具,开发服务器启动速度比Webpack快好几个量级,热更新也是毫秒级的。项目创建好之后安装基础依赖:
bash复制cd mall-web
npm install
npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue
工程结构的组织方式关乎后续开发的效率。我建议的目录结构如下:
code复制src
├── api // 接口请求封装
├── assets // 静态资源
├── components // 公共组件
├── router // 路由配置
├── stores // Pinia状态管理
├── utils // 工具函数
├── views // 页面组件
├── App.vue
└── main.js
每个模块单页一个文件夹,内部再按index.vue、components子目录组织,这种结构在页面多的时候不至于乱成一锅粥。
4.2 Vue Router路由与页面骨架设计
在线商城系统的页面结构分三块:首页(商品列表)、商品详情页、个人中心(包含购物车和订单)。路由配置的核心代码如下:
javascript复制import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/product/:id', name: 'ProductDetail', component: () => import('@/views/ProductDetail.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/register', name: 'Register', component: () => import('@/views/Register.vue') },
{
path: '/cart',
name: 'Cart',
component: () => import('@/views/Cart.vue'),
meta: { requiresAuth: true } // 需要登录才能访问
},
{
path: '/orders',
name: 'Orders',
component: () => import('@/views/Orders.vue'),
meta: { requiresAuth: true }
}
]
注意路由懒加载的写法,组件通过() => import('@/views/Home.vue')动态导入,这样首屏只加载当前页面需要的代码,整体打包体积更小。meta.requiresAuth字段配合全局前置守卫,可以实现“未登录用户访问购物车自动跳转到登录页”的效果:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
页面整体布局我用的方案是:顶部导航栏是一个公共组件,包含Logo、搜索框、购物车图标、用户下拉菜单;底部是Footer。内容区域通过<router-view>渲染,这种经典电商布局用户一看就懂。
4.3 Axios封装与接口对接技巧
前端调用后端接口必须封装Axios。不封装的话,每个页面都要重复写axios.get(url, { headers: {...} }),代码冗余不说,拦截器的统一处理也无从谈起。我项目里的Axios封装如下:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: 'http://localhost:8080/api',
timeout: 10000
})
// 请求拦截器:自动携带token
request.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器:统一处理业务码和HTTP错误
request.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) {
return res.data
} else if (res.code === 401) {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
router.push('/login')
return Promise.reject(new Error(res.message))
} else {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
},
error => {
ElMessage.error(error.message || '网络异常')
return Promise.reject(error)
}
)
export default request
封装好之后,一个典型的API调用就变成了一行代码:
javascript复制// api/product.js
import request from '@/utils/request'
export const getProductList = (params) => request.get('/products', { params })
export const getProductDetail = (id) => request.get(`/products/${id}`)
// 页面里使用
const { data } = await getProductList({ pageNum: 1, pageSize: 8 })
统一响应拦截器处理了token过期、业务异常、网络错误各种情况,页面里不需要重复写try catch,代码质量直接上升一个档次。
4.4 购物车与订单页的核心交互逻辑
购物车页的交互是整个前端最考验功力的部分。修改商品数量、勾选商品同步更新底部结算栏的合计金额、批量删除,这些功能都要做到响应式实时更新。
我项目中用Pinia管理购物车状态:
javascript复制import { defineStore } from 'pinia'
import { getCartList, updateCartQuantity, deleteCartItem } from '@/api/cart'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
loading: false
}),
getters: {
totalPrice: (state) => {
return state.items
.filter(item => item.checked)
.reduce((sum, item) => sum + item.price * item.quantity, 0)
.toFixed(2)
},
selectedCount: (state) => {
return state.items
.filter(item => item.checked)
.reduce((sum, item) => sum + item.quantity, 0)
}
},
actions: {
async fetchCart() {
this.loading = true
try {
this.items = await getCartList()
} finally {
this.loading = false
}
}
}
})
totalPrice是一个getter计算属性,它依赖items数组中的checked和quantity字段,只要购物车数据发生变化,合计金额就会自动重新计算。这种状态管理方案带来的开发体验提升是巨大的,页面组件只需要简单store.items渲染列表,不需要来回传参和触发事件。
订单确认页的核心工作是把“选中的购物车商品”汇总展示,并填写收货地址,然后提交创建订单。提交成功后跳转到订单列表页。整个流程走通一遍,你对Vue3的响应式、组件通信、路由跳转、状态管理这些核心概念就会有非常深刻的理解。
4.5 Vue3开发的几个关键差异点
最后聊几个Vue2老手转Vue3最容易踩的坑。第一,Vue2的data选项变成了setup()函数或<script setup>里的ref/reactive;第二,this.$emit变成了defineEmits,props变成了defineProps,响应式数据要用.value访问;第三,不再有Vue.prototype.$xxx这种全局挂载方式,全局方法通过app.config.globalProperties配置。
我用一个简单例子演示<script setup>写法的清爽:
vue复制<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const keyword = ref('')
const router = useRouter()
const handleSearch = () => {
if (keyword.value.trim()) {
router.push({ path: '/', query: { keyword: keyword.value } })
}
}
</script>
<template>
<div class="search-bar">
<input v-model="keyword" placeholder="搜索商品" @keyup.enter="handleSearch" />
<button @click="handleSearch">搜索</button>
</div>
</template>
没有export default,没有this,变量声明和模板使用非常自然。Vue3的Composition API在代码组织上更灵活,同一个功能的逻辑可以聚合在一起,而不是像Options API那样必须分散在不同的选项块里。
5. 环境准备、项目启动与常见问题排查
5.1 本机环境清单与安装说明
为了保证你拿到的项目代码在自己电脑上能顺利跑起来,我把完整的环境依赖列出来。这些软件的版本号我都是实际测试过的,别随便升级大版本,否则可能因为兼容性问题报一些莫名其妙的错。
| 软件 | 版本要求 | 用途 |
|---|---|---|
| JDK | 17及以上 | 运行SpringBoot3 |
| Maven | 3.6及以上 | 后端依赖管理 |
| MySQL | 8.0及以上 | 数据库 |
| Node.js | 16及以上 | 前端构建环境 |
| IDEA | 社区版/旗舰版均可 | 开发IDE |
| Navicat/DBeaver | 任意版本 | 数据库可视化工具 |
数据库方面,导入脚本的方式很简单:打开Navicat,新建数据库mall,字符集选utf8mb4,排序规则选utf8mb4_general_ci,然后右键选择“运行SQL文件”,把项目附带的mall.sql导入进去,6张表就全部建好了,而且脚本里已经内置了商品分类、轮播图等基础测试数据。
5.2 后端的关键配置与启动步骫
后端的核心配置在application.yml文件里。你要改的主要是数据库连接信息:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 你的密码
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
确认无误后,启动方式二选一:直接运行MallApplication.java的main方法,或者在命令行执行mvn spring-boot:run。看到控制台打印出Tomcat started on port 8080就说明后端启动成功了。
5.3 前端启动与跨域配置
前端启动步骤:
bash复制cd mall-web
npm install # 第一次运行才需要
npm run dev
终端显示Local: http://localhost:5173/说明启动成功。但此时直接访问会发现登录不了、商品加载不出来,因为浏览器拦截了跨域请求。前端运行在5173端口,后端在8080端口,端口不同就产生了跨域。
解决跨域的标准做法是在后端配置CORS。SpringBoot里写一个配置类即可:
java复制@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);
}
}
注意allowCredentials(true)表示允许携带Cookie,但前端用token认证,理论上不需要cookie,这个配置主要是兼容某些浏览器行为。加上CORS配置后刷新页面,数据就能正常加载了。
5.4 常见报错速查表
这里整理了我在实际辅导过程中遇到频率最高的几个报错,以及对应的解决方案,你遇到问题可以先对照自查。
| 报错现象 | 根因分析 | 解决方案 |
|---|---|---|
java: 无效的源发行版: 17 |
IDEA的Project SDK没有切换到JDK 17 | File -> Project Structure -> Project SDK选17,Language Level选17 |
Access denied for user 'root'@'localhost' |
数据库账号或密码不对 | 检查application.yml里的username和password是否与你本地MySQL一致 |
Table 'mall.t_user' doesn't exist |
数据库脚本未导入或导错库 | 确认SQL脚本已在名为mall的数据库下执行,且表名前缀正确 |
npm ERR! ERESOLVE unable to resolve dependency tree |
依赖版本冲突 | 先执行npm cache clean --force,再删除node_modules后重新npm install |
Failed to resolve component: el-button |
Element Plus未正确引入 | main.js里检查和确认完整引入了Element Plus或按需引入配置正确 |
| 前端请求接口返回404 | 后端接口路径与前端请求路径不一致 | 打开F12控制台查看具体请求URL,检查后端Controller的@RequestMapping前缀 |
请求返回401 Unauthorized |
token未携带、已过期或拦截器放行路径不对 | 确认前端Axios拦截器已设置Authorization头;确认WebConfig里放行了登录注册接口 |
5.5 答辩演示的准备建议
项目顺利跑通只是第一步,答辩时的演示效果直接影响最终成绩。我建议你提前做三件事。
第一,准备一份“测试数据剧本”。不要到了现场才临时搜索“手机”关键词然后发现没数据,提前确认首页有推荐的轮播图,商品列表有至少10条真实感强的商品记录,每个分类下面都有东西可看。演示的顺序建议是:首页浏览 → 搜索商品 → 查看详情 → 加入购物车 → 结算下单 → 查看订单列表 → 个人中心 → 退出登录。
第二,准备“亮点讲解词”。你在答辩前选两三个技术亮点反复练习讲解,比如JWT鉴权流程、购物车库存的事务控制、MyBatis-Plus的分页插件、Pinia状态管理。讲到某一个页面的时候自然引出这些技术点,老师一问你就答得行云流水,这个项目的深度就体现出来了。
第三,准备“避坑预案”。万一现场网络不好启动慢,或者数据库连不上,至少知道哪里能快速修。我见过太多同学现场演示翻车,其实多数情况就是后端没起来或者前端代理配错。提前在手机备忘录里存一份“紧急启动手册”,关键时刻能救命。
回到我最开始提到的那个问题——为什么很多人照着教程做还是做不出一个完整的在线商城系统?不是因为代码有多难,而是因为信息碎、版本乱、缺数据库脚本,任何一个环节掉链子项目就卡死。这套项目把教程、源码、数据库、文档、答疑都配齐了,你拿到手不是一个“作业”,而是一套能真正理解、能讲清楚原理、能应对任何追问的完整作品。我常说毕业设计的最高境界不是“做出来”,而是“能讲明白”。希望这篇博文能帮你达到这个境界。
