Spring Boot+Vue记账系统开发实战:从数据库设计到JWT认证部署
简介基于Springboot与Vue构建的大学生智能消费记账系统是一份适合毕业设计、课程设计或前后端分离实战入门的完整源码案例。系统围绕大学生记账场景提供账单管理、分类统计、数据可视化、预算提醒等功能帮助你理解SpringBoot自动配置、数据持久化、RESTful API设计以及Vue组件化、Vue Router路由管理等核心技术。压缩包共352个文件大小22.93MB包含82个Java后端类、36个Vue页面组件、161个SVG图标以及SQL数据库脚本、XML配置、启动批处理和mp4演示视频结构清晰便于检索。已有138人学习下载。资源内置一键运行脚本和数据库脚本可快速搭建本地环境对照源码与演示视频能够完整走通需求分析、数据库设计、前后端联调、JWT认证及图表展示的开发链路对毕业设计答辩和项目实践能力提升都很有帮助。1. 大学生消费记账系统期末作业到毕业设计的完整落地样本很多人手机里装过记账App能坚持超过一个月的没几个。但自己从零写一个消费记账系统体验完全不同——你会真正理解一笔账单从页面输入到数据库落盘、再到图表展示的完整链路。这套基于 Springboot Vue 的大学生智能消费记账系统正好覆盖了这条链路的全部环节后端是 Springboot 框架的标准分层结构前端是 Vue 2 Element UI 的单页应用配合 MySQL 存储和 ECharts 可视化。它适合三类人正在做毕业设计、需要完整可运行项目做参考的学生想系统学习前后端分离项目如何落地、但不打算从零搭框架的开发者以及需要一个能改能扩展的基础工程、准备在此基础上加功能的初级程序员。这套源码最大的价值不是“能跑通”而是结构清晰、注释到位、扩展点明确你在它基础上加预算预警、账单导入导出、月度统计报表等功能都能很自然地对上号。2. 技术选型与数据库设计为什么这套组合是毕业设计的最优解2.1 Springboot Vue 的搭配逻辑先聊选型理由。毕业设计最常见的技术组合就是 Springboot Vue这不是巧合而是这套组合在“工作量展示”和“技术覆盖面”之间取得了一个很好的平衡。Springboot 负责后端业务逻辑和接口暴露它内置 Tomcat、自动配置、Starter 机制能省掉大量 XML 配置Vue 负责前端页面交互组件化开发让代码结构一目了然。前后端通过 JSON 交互天然就是企业级项目的标准开发模式。Springboot 的优势在于“快”不用像 SSM 时代那样写一堆配置文件一个SpringBootApplication注解就能启动整个应用。对没有太多经验的开发者来说这意味着你不用被环境配置劝退能更快进入业务代码。而 Vue 的优势在于“清晰”模板语法直白数据驱动视图改了数据页面就变做账单一类的增删改查界面非常顺手。这套源码的技术栈具体是后端 Springboot 2.x MyBatis-Plus MySQL 8.x JJWTJava JWT 库前端 Vue 2 Vue Router Vuex Axios Element UI ECharts。选 MyBatis-Plus 而不是原生 MyBatis原因很简单——单表 CRUD 不用手写 SQL代码量少一半而且分页插件开箱即用特别适合做账单列表这种带条件查询的场景。JWT 做登录鉴权比 Session 更符合前后端分离的架构后端不存登录状态前端把 Token 存到本地每次请求带在 Header 里即可。2.2 数据库表设计与核心字段记账系统的核心数据模型围绕“用户”和“账单”两张主表展开。用户表负责登录认证信息账单表记录每一笔消费或收入。这套源码的建表脚本包含 5 张表user用户表、bill账单表、category消费分类表、budget预算表、bill_tag账单标签关联表。实际开发中最常用的是前三个把这三张表设计好系统的主干就立住了。CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码(BCrypt加密), nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像地址, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE bill ( id INT NOT NULL AUTO_INCREMENT COMMENT 账单ID, user_id INT NOT NULL COMMENT 所属用户ID, type TINYINT NOT NULL DEFAULT 1 COMMENT 类型:1支出 2收入, amount DECIMAL(10,2) NOT NULL COMMENT 金额, category_id INT DEFAULT NULL COMMENT 分类ID, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, bill_date DATE NOT NULL COMMENT 消费日期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_user_date (user_id, bill_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账单表;这里有两个细节需要理解。第一为什么user_id和bill_date建联合索引——因为记账系统最频繁的查询就是“查某个人某段时间的账单”这个联合索引能让这类查询走索引而不是全表扫描。第二为什么amount用DECIMAL(10,2)而不是FLOAT——因为浮点数在 MySQL 里做货币运算会产生精度丢失0.1 0.2 算出来是 0.30000000000000004这在记账场景是必须避免的错误。category_id关联分类表分类表里预置了餐饮、交通、购物、娱乐、学习、医疗等常见分类。你也可以扩展比如加一个icon字段存分类图标前端展示会更友好。预算表则按“用户 月份 分类”做维度控制用来实现在一定周期内只允许消费一定金额的提醒逻辑。2.3 后端项目结构与核心依赖说明拿到源码后先看项目结构再启动。后端是一个标准的 Maven 多模块或单模块结构这套源码用的是单模块但包名分层非常规范controller、service、mapper、entity、config、common、utils。如果你在维护自己的毕设建议一个包都不要乱放答辩时老师看包结构就能给你加分。打开pom.xml能看到核心依赖的版本配置。我把关键部分贴出来你导入项目时直接对照检查parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.6.13/version /parent dependencies !-- Web起步依赖内置Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus简化单表CRUD -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.2/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- JWT无状态登录令牌 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency !-- Lombok简化实体类代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies需要注意spring-boot-starter-web里自带的 Tomcat 版本。如果你本机装了更高版本的 Tomcat也以 Maven 依赖里的为准不用单独装启动时用mvn spring-boot:run或直接跑主类的main方法即可。MyBatis-Plus 3.5.2 要求 JDK 8如果你的 JDK 版本是 11 或 17也能正常工作但建议保持项目默认的 JDK 8因为 JDK 8 是绝大多数高校机房和自备电脑的稳定环境。3. 后端 Springboot 实现JWT 认证与账单核心接口拆解3.1 登录认证JWT 工具类与拦截器配置登录鉴权是这套系统后端最核心的部分也是面试和答辩时最容易追问的点。整套机制可以拆成三步登录成功后签发 Token → 前端把 Token 存下来 → 后续每次请求带着 Token由后端的拦截器校验合法性。源码里有一个JwtUtil类和一个JwtInterceptor拦截器前者负责生成和解析 Token后者负责拦截请求、校验 Token。先看 JWT 工具类的实现Component public class JwtUtil { // 密钥实际项目中应放配置文件通过Value注入 private static final String SECRET_KEY your-secret-key-please-change; // 过期时间2小时 private static final long EXPIRATION 2 * 60 * 60 * 1000; /** * 生成Token * param userId 用户ID * param username 用户名 * return JWT字符串 */ public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRATION)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } /** * 解析Token校验是否合法 * param token 前端传递的Token * return Claims 包含用户信息 */ public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }这段代码里的逻辑要理解清楚。setSubject(username)把用户名放进 Token 主体方便后续从 Token 里直接拿到用户名claim(userId, userId)是自定义声明用来存用户 ID——这是后续查询“当前登录用户的账单”的关键signWith用 HS256 算法签名密钥就是SECRET_KEY。这套机制的本质是“签名防篡改”——服务端不存 Session只靠密钥验证 Token 是否被改动过。再来看拦截器的核心逻辑。拦截器要做三件事从请求头取出 Token校验 Token 是否合法把用户信息放进请求上下文供后续使用。Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 String uri request.getRequestURI(); if (uri.contains(/api/user/login) || uri.contains(/api/user/register)) { return true; } // 从请求头获取Token String token request.getHeader(Authorization); if (token null || token.isEmpty()) { // 返回401状态码提示前端跳转登录页 response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } try { // 校验Token并获取用户信息 Claims claims jwtUtil.parseToken(token); // 把用户ID放入request作用域供后续业务使用 request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { // Token非法或过期 response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\Token无效或已过期\}); return false; } } }注意这里有两个细节。第一登录和注册接口必须放行否则会形成“无法登录的死循环”第二request.setAttribute(userId, ...)这一步很关键——后面所有业务接口都能从 request 里取出当前登录用户的 ID不需要前端把 userId 当作参数传过来这是安全设计上的最佳实践也能防止用户篡改参数查别人的账单。拦截器写好之后需要注册到 WebMvcConfigurer 里才能生效。源码的WebConfig类里有对应的addInterceptors方法注册时指定拦截路径为/api/**即可这样就覆盖了除登录注册外的所有接口。3.2 账单接口分页条件查询与增删改查账单模块是系统的核心业务也是我最建议你花时间读代码的部分。它涉及完整的增删改查 分页 条件统计写法和套路可以直接迁移到任何“列表管理”类页面。先看BillController的接口定义结构再看BillService的实现细节。RestController RequestMapping(/api/bill) public class BillController { Autowired private BillService billService; /** * 分页查询当前用户的账单 * 支持按类型、分类、日期范围筛选 */ GetMapping(/page) public Result page(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) Integer type, RequestParam(required false) Integer categoryId, RequestParam(required false) String startDate, RequestParam(required false) String endDate, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); return Result.success(billService.pageQuery(pageNum, pageSize, userId, type, categoryId, startDate, endDate)); } /** * 新增账单 */ PostMapping(/add) public Result add(RequestBody Bill bill, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); bill.setUserId(userId); billService.saveBill(bill); return Result.success(添加成功); } /** * 修改账单 */ PutMapping(/update) public Result update(RequestBody Bill bill, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); bill.setUserId(userId); billService.updateBill(bill); return Result.success(修改成功); } /** * 删除账单 */ DeleteMapping(/delete/{id}) public Result delete(PathVariable Long id, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); billService.deleteBill(id, userId); return Result.success(删除成功); } }这里的HttpServletRequest request参数从哪来就是拦截器放行后setAttribute里存进去的userId。这样设计的好处是接口签名里不出现与“当前登录用户”相关的参数前端不管传什么都被忽略后端永远从可信的认证上下文里拿用户 ID。如果你做毕设时问到“如何防止用户越权操作”这段代码就是标准答案。再看BillService里分页查询的具体实现这是 MyBatis-Plus 最拿手的场景public PageBillVO pageQuery(Integer pageNum, Integer pageSize, Long userId, Integer type, Integer categoryId, String startDate, String endDate) { // 1. 构建查询条件 LambdaQueryWrapperBill wrapper new LambdaQueryWrapper(); // 2. 强制过滤当前用户的数据 wrapper.eq(Bill::getUserId, userId); // 3. 可选条件动态拼装 if (type ! null) { wrapper.eq(Bill::getType, type); } if (categoryId ! null) { wrapper.eq(Bill::getCategoryId, categoryId); } if (StringUtils.isNotBlank(startDate)) { wrapper.ge(Bill::getBillDate, startDate); } if (StringUtils.isNotBlank(endDate)) { wrapper.le(Bill::getBillDate, endDate); } // 4. 按消费日期倒序最新账单排前面 wrapper.orderByDesc(Bill::getBillDate); // 5. 分页查询 PageBill page new Page(pageNum, pageSize); PageBill result billMapper.selectPage(page, wrapper); // 6. 转VO补充分类名称等冗余字段 return convertToVO(result); }分页条件查询的套路就是这六步先LambdaQueryWrapper构建条件容器再强制过滤用户维度防止越权接着按需拼装备选条件然后排序再交给 MyBatis-Plus 的selectPage完成分页最后把实体类转成带分类名的 VO 对象返回前端。这里的LambdaQueryWrapper用方法引用Bill::getUserId代替字符串字段名编译期就能发现字段写错的低级问题是 MyBatis-Plus 推荐的做法。新增和修改部分的逻辑相对简单但有一个边界条件需要处理bill_date字段如果前端没传需要默认取当天日期。源码里在saveBill里做了空值兜底这个细节看似小但直接影响“快速记账”场景下的数据完整性我建议你保留这个逻辑。3.3 统计接口按分类聚合和按月趋势记账系统另一个核心功能是统计图表。前端 ECharts 画的饼图和折线图数据来源是后端两个统计接口。一个按分类聚合当前用户某个月的支出金额用来画饼图一个按日期分组统计某段时间的每日支出用来画折线图。这两个接口都是 SQL 聚合查询的典型应用。public ListMapString, Object getCategorySummary(Long userId, String month) { // 使用自定义SQL进行聚合查询 ListMapString, Object result billMapper.selectCategorySummary(userId, month); // 返回数据结构: [{name: 餐饮, value: 1234.50}, {name: 交通, value: 300.00}] return result; }对应的 Mapper XML 里的 SQL 是这个样子select idselectCategorySummary resultTypemap SELECT c.name AS name, SUM(b.amount) AS value FROM bill b LEFT JOIN category c ON b.category_id c.id WHERE b.user_id #{userId} AND b.type 1 AND DATE_FORMAT(b.bill_date, %Y-%m) #{month} GROUP BY b.category_id ORDER BY value DESC /select这里用LEFT JOIN关联分类表取分类名称作为图表维度。为什么不用INNER JOIN因为要兼容分类被删除的情况——如果用户删了一个分类但历史账单里还有数据内连接会把那些账单丢掉左连接则能保留记录只是分类名为空。聚合查询的GROUP BY配合SUM是数统计场景的基本功写的时候注意两点日期格式化用DATE_FORMAT(b.bill_date, %Y-%m)保证月份匹配金额精度用DECIMAL不会在聚合时出问题。如果数据量大了还可以加一个bill_date的索引来加速这类统计查询。4. 前端 Vue 实现路由守卫、Axios 封装与 ECharts 可视化4.1 前端工程结构与路由配置前端部分用的是 Vue 2 Element UI这是目前中文社区资料最多、出问题最好搜的组合。拿到前端代码后先看目录结构src/api下面是接口调用封装src/router是路由配置src/store是 Vuex 状态管理src/views是页面组件src/utils是工具函数。这个结构是 Vue 项目的标准布局几乎每个 Vue 项目都长这样学会了看哪个项目都不慌。路由配置里最有技术含量的点是“登录态校验”和“页面权限控制”。源码里给需要登录才能访问的页面配置了meta: { requiresAuth: true }然后在路由守卫里统一判断// src/router/index.js import Vue from vue import Router from vue-router Vue.use(Router) const router new Router({ routes: [ { path: /login, name: Login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/Dashboard.vue), meta: { title: 首页, requiresAuth: true } }, { path: bill/list, name: BillList, component: () import(/views/bill/BillList.vue), meta: { title: 账单管理, requiresAuth: true } }, { path: bill/statistics, name: BillStatistics, component: () import(/views/bill/BillStatistics.vue), meta: { title: 统计报表, requiresAuth: true } } ] } ] }) // 全局前置守卫 router.beforeEach((to, from, next) { // 从localStorage读取Token const token localStorage.getItem(token) // 如果目标页面需要登录但没有Token跳转登录页 if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } }) export default router这里有个容易踩的细节query: { redirect: to.fullPath }。它的作用是把用户想访问的地址记录在登录页的 URL 参数里登录成功后跳回原目标页面而不是每次登录都回首页。这个小交互设计答辩时能体现你对用户体验的思考。路由守卫的写法也顺便回答了“如何控制未登录用户不能访问内部页面”这个问题——不用在每个组件里判断路由层面统一拦截即可。4.2 Axios 拦截器与请求封装前端另一个必备的封装是 Axios 拦截器。它的作用是在每次发请求前带上 Token在收到响应后统一处理业务状态码和 HTTP 错误码。源码的src/utils/request.js就是一个标准的 Axios 封装写法// src/utils/request.js import axios from axios import { Message } from element-ui import router from /router // 创建Axios实例 const service axios.create({ baseURL: /api, // 请求前缀开发环境走代理 timeout: 10000 // 10秒超时 }) // 请求拦截器每次请求前自动带上Token service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }, error { return Promise.reject(error) } ) // 响应拦截器统一处理返回结果 service.interceptors.response.use( response { const res response.data // 业务状态码判断 if (res.code 200) { return res } else if (res.code 401) { // 登录过期清理Token并跳转登录页 localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(请重新登录)) } else { Message.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg || 请求失败)) } }, error { Message.error(error.message || 网络错误) return Promise.reject(error) } ) export default service这个拦截器解决了三个场景请求头里手动写 Token 容易漏统一在拦截器里加一劳永逸后端返回 401 时自动清理登录态并跳转登录页用户不会停留在假登录状态业务错误统一弹提示不用每个页面自己写if (res.code ! 200)的判断。实际开发中你只需要在业务代码里这样调用// src/api/bill.js — 账单相关接口的统一出口 import request from /utils/request // 分页查询账单 export function getBillPage(params) { return request({ url: /bill/page, method: get, params }) } // 新增账单 export function addBill(data) { return request({ url: /bill/add, method: post, data }) } // 统计:分类汇总 export function getCategorySummary(params) { return request({ url: /bill/categorySummary, method: get, params }) }注意baseURL: /api和url: /bill/page最终拼成的请求地址是/api/bill/page与后端 Controller 的RequestMapping(/api/bill)对应。开发环境下前端通过 Vue CLI 的代理把请求转发到后端 8080 端口生产环境则通过 Nginx 反向代理这部分在部署章节再展开。4.3 账单页面的增删改查与表单校验账单管理页是前端代码量最大的页面。它的逻辑你拆开看不难顶部是筛选条件类型、分类、日期范围中间是账单表格右下角有新增和编辑按钮。表格自带分页控件数据来自后端分页接口。这里我把关键的数据绑定和分页逻辑贴出来!-- src/views/bill/BillList.vue (模板部分) -- template div classbill-list !-- 筛选区域 -- el-form :inlinetrue :modelqueryForm classfilter-form el-form-item label类型 el-select v-modelqueryForm.type placeholder全部 clearable el-option label支出 :value1/el-option el-option label收入 :value2/el-option /el-select /el-form-item el-form-item label分类 el-select v-modelqueryForm.categoryId placeholder全部 clearable el-option v-foritem in categoryList :keyitem.id :labelitem.name :valueitem.id /el-option /el-select /el-form-item el-form-item label日期 el-date-picker v-modelqueryForm.dateRange typedaterange range-separator至 start-placeholder开始日期 end-placeholder结束日期 value-formatyyyy-MM-dd /el-date-picker /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form !-- 表格区域 -- el-table :datatableData v-loadingloading border stripe el-table-column propbillDate label日期 width120/el-table-column el-table-column propcategoryName label分类 width100/el-table-column el-table-column propamount label金额 width120 template slot-scope{ row } span :style{ color: row.type 1 ? #f56c6c : #67c23a } {{ row.type 1 ? - : }}{{ row.amount }} /span /template /el-table-column el-table-column propremark label备注/el-table-column el-table-column label操作 width150 template slot-scope{ row } el-button typetext clickhandleEdit(row)编辑/el-button el-button typetext stylecolor:#f56c6c clickhandleDelete(row)删除/el-button /template /el-table-column /el-table !-- 分页 -- el-pagination current-changehandlePageChange :current-pagequeryForm.pageNum :page-sizequeryForm.pageSize :totaltotal layouttotal, prev, pager, next background /el-pagination /div /template页面对应的方法逻辑里分页参数和筛选条件都集中在queryForm里每次查询或翻页都带上完整的筛选条件。这里的slot-scope{ row }是 Vue 2.6 作用域插槽的写法用来在表格里自定义展示内容注意 2.x 的写法不用#default——如果你是照着 Vue 3 的教程学这个语法差异要特别注意。新增和修改复用同一个弹窗表单组件用dialogVisible控制显隐打开编辑时把表格行数据Object.assign一份传给表单避免直接修改表格数据造成视图异常。表单校验则用 Element UI 的rules规则金额必填且必须大于 0日期必选备注可空。校验规则的写法比较固定rules: { type: [{ required: true, message: 请选择类型, trigger: change }], amount: [ { required: true, message: 请输入金额, trigger: blur }, { validator: (rule, value, callback) { if (value 0) { callback(new Error(金额必须大于0)) } else { callback() } }, trigger: blur } ], billDate: [{ required: true, message: 请选择日期, trigger: change }] }自定义校验器validator在这里是必须的——Element UI 自带的数字校验没法判断金额是否大于 0需要自己写。很多新手在这里用type: number校验结果发现输入字符时表单不通过、输入负数时也拦不住就是这个原因。4.4 ECharts 统计图表的动态渲染统计报表页是整套系统里视觉效果最好的部分也通常是答辩时被问得最多的地方。源码里用 ECharts 画了三个图按分类的支出饼图、按月趋势折线图、收入支出对比柱状图。前端调统计接口获取数据再动态塞进 ECharts 的option里。饼图部分的核心代码// src/views/bill/BillStatistics.vue (部分) import * as echarts from echarts export default { data() { return { categoryChart: null, categoryData: [] } }, mounted() { this.initCategoryChart() this.loadCategoryData() }, methods: { initCategoryChart() { this.categoryChart echarts.init(this.$refs.categoryChart) }, async loadCategoryData() { // 获取当前月份分类汇总数据 const res await getCategorySummary({ month: this.currentMonth }) this.categoryData res.data this.renderCategoryChart() }, renderCategoryChart() { // 把接口数据映射成ECharts的饼图数据格式 const pieData this.categoryData.map(item ({ name: item.name, value: item.value })) const option { tooltip: { trigger: item, formatter: {b}: {c}元 ({d}%) }, legend: { orient: vertical, left: left }, series: [{ name: 支出构成, type: pie, radius: 60%, data: pieData, emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: rgba(0, 0, 0, 0.5) } } }] } this.categoryChart.setOption(option) } } }ECharts 用起来就三步init初始化实例、组装option配置、setOption渲染。唯一的雷点是容器需要有明确的宽度和高度否则图表渲染不出来。源码的页面里给图表容器设置了height: 400px如果你自己扩展图表记得给每个容器都设高度。饼图formatter: {b}: {c}元 ({d}%)里的{b}是分类名{c}是金额{d}是占比。展示效果是“餐饮: 234.5元 (35.2%)”这种格式一眼就能看出钱花在哪。折线图的写法类似只是type: line数据源是“按日汇总曲线”。到这里前端从路由到请求再到图表渲染的链路已经完整打通了。5. 部署联调避坑跨域、MySQL 版本与前端打包的五个坑5.1 本地启动顺序与数据库初始化连不上数据库就是第一步翻车拿到源码的第一步我的习惯是先看 README 再动代码。这套源码的 README 里写了数据库初始化脚本的位置和本地启动步骤但你完全可能不看 README 直接跑然后卡在数据库连接上。下面是我推荐的启动顺序先建库、再导数据、再启动后端、最后启动前端。数据库初始化用 Navicat 或命令行都行关键是把sql脚本执行到位。执行完后用下面这条命令验证一遍核心表USE student_bill; SHOW TABLES; SELECT id, username FROM user;如果执行SHOW TABLES后看不到 5 张表说明脚本没执行成功检查是不是选错了数据库。如果SELECT能查出预置的测试账号说明数据初始化没问题。接下来修改后端application.yml里的数据库连接信息server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/student_bill?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver 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这里最容易翻车的点有两个。第一个是serverTimezoneAsia/Shanghai——MySQL 8 默认时区是 UTC如果不加这个参数插入日期时会出现“时间比本地时间早 8 小时”的诡异现象。第二个是驱动类名必须用com.mysql.cj.jdbc.DriverMySQL 8.x如果你用 MySQL 5.x 的老驱动类名com.mysql.jdbc.Driver在 MySQL 8 里直接启动失败。如果你本机装的是 MySQL 5.7建表脚本里的utf8mb4和DATETIME DEFAULT CURRENT_TIMESTAMP也完全支持不需要额外修改。后端启动成功的标志是控制台出现 Tomcat started on port(s): 8080且没有红色异常堆栈。如果启动失败先检查数据库服务和端口再用telnet 127.0.0.1 3306验证连通性这两步排查完80% 的启动失败都能解决。5.2 跨域与代理配置为什么前端请求总是 404 或 CORS 报错前端开发服务器默认跑在 8080 端口如果你按顺序启动后端占了 8080Vue CLI 会询问你是否换端口选是。浏览器的同源策略规定8080 的前端页面直接请求 8080 的后端接口属于跨域请求会被浏览器拦截。后端源码里已经配置了跨域支持但更推荐的做法是用 Vue CLI 的代理转发让浏览器以为请求是同源的。前端根目录的vue.config.js里配置了开发代理// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, // 后端地址 changeOrigin: true } } } }配置生效后前端页面里请求/api/bill/pageVue CLI 的 dev server 会把它转发到http://localhost:8080/api/bill/page。浏览器看到的请求地址还是localhost:8081/api/bill/page不触发跨域拦截。这个代理只在开发环境有效——生产环境你打包成静态文件部署后需要 Nginx 来处理同样的转发逻辑。关于后端配置跨域比如用CrossOrigin注解或全局 CORS 配置的权限我更建议你保留后端的 CORS 配置但不要依赖它——因为生产环境用 Nginx 同源部署后后端的 CORS 配置就没有意义了留着不影响功能但别把它当主力方案。5.3 从开发到上线前端打包放进 Springboot 的两种方式调试阶段结束后如果要把系统部署到服务器上给老师演示最省事的方式是“前端打包后由后端托管”。这样做的好处是只起一个进程不用担心两个端口、不用配 Nginx演示时只需要启动一个 Springboot 应用。这里介绍我最常用的两种做法。方式一打包后手动拷进 Springboot 的src/main/resources/static目录。前端执行# 前端项目根目录执行打包 npm run build打包完成后dist目录下就是完整的静态文件。把dist里的所有内容拷贝到后端项目的src/main/resources/static目录下重新启动 Springboot访问http://localhost:8080就能直接看到前端页面——因为 Springboot 自带静态资源映射static目录下的文件会被自动托管。方式二用 Maven 插件在构建后端时自动拷贝前端产物。在后端pom.xml里配置build plugins plugin artifactIdmaven-resources-plugin/artifactId executions execution idcopy-frontend/id phaseprepare-package/phase goals goalcopy-resources/goal /goals configuration outputDirectory${project.build.outputDirectory}/static/outputDirectory resources resource directory${project.basedir}/../frontend/dist/directory /resource /resources /configuration /execution /executions /plugin /plugins /build方式二适合反复构建的场景每次mvn package都会自动拷贝最新的前端产物。但注意一个坑单页应用的路由是 history 模式时访问/bill/list这类页面刷新会 404因为 Springboot 静态资源映射只处理文件存在的情况。解决方法是改前端路由为 hash 模式http://localhost:8080/#/bill/list或者在 Springboot 里加一个“除 API 外的路径都转发到 index.html”的控制器。对于毕设演示直接改用 hash 模式最省心——Vue Router 的createWebHashHistory即可。5.4 常见坑盘点三条血泪经验坑一白屏 控制台报Uncaught SyntaxError: Unexpected token 。现象前端打包后部署到服务器打开页面一片空白控制台报语法错误查看资源文件发现返回的是 index.html 的 HTML 内容而不是 JS 文件。原因Nginx 或 Springboot 没配置 SPA 的 history 路由回退导致访问/bill/list时服务器尝试读取bill/list目录失败后返回了 HTML 错误页。解决改为 hash 模式或在服务器层加try_files $uri $uri/ /index.html;Nginx或对应的转发逻辑。我用过的是这个场景里最后选择了 hash 模式省事且稳定。坑二登录成功但立刻被弹回登录页。现象输入账号密码登录成功跳转首页没几秒又被拦截器弹回登录页。原因Token 过期时间设置太短比如毫米级写错或者后端返回给前端的 Token 字段命名不一致——前端localStorage.getItem(token)取不到值请求头里的 Authorization 是空的。解决检查后端登录接口返回的 JSON 结构确保data.token字段和前端取值一致再检查 JwtUtil 里的过期时间单位是否毫秒。我排查这个问题时发现源码返回的是data.token而我写的前端读的却是data.accessToken改一个字段名就解决了。坑三新增账单后列表没刷新或者分页数据对不上。现象提交新增表单成功回到列表页却发现数据不在第一页往下翻几页才看到。原因新增成功后没有回到第一页而当前停留的页码超过了新数据的总页数。解决新增成功后把pageNum重置为 1再调用查询接口。这是所有分页场景都会遇到的坑不只是记账系统。那以后我的习惯是任何新增或删除操作后都强制回第一页并重新加载。6. 进阶方向从“能跑”到“能答辩”的三个加分项源码本身是完整的但毕业设计要拿高分光“能跑”不够得让评委看到你“理解了系统还能扩展”。下面三个方向改动量不大但每加一个答辩可说的内容就多一层。第一个加分项是预算预警。数据模型里已经有budget表前端只需要在账单新增后调一个“本月已支出金额”的统计接口和预算表里设置的上限做对比超了就弹提示。核心代码就几行逻辑// 在BillService的saveBill方法末尾追加预算检查 BigDecimal monthExpense billMapper.selectMonthExpense(userId, currentMonth); Budget budget budgetMapper.selectByUserAndMonth(userId, currentMonth); if (budget ! null monthExpense.compareTo(budget.getLimitAmount()) 0) { // 通过WebSocket或前端主动轮询提示 return Result.warn(本月支出已超过预算!); }第二个加分项是账单导入导出。用 EasyExcel 或 Apache POI 写一个导入接口前端加一个“导入 Excel”按钮能把学校附近小店的微信账单导出文件导进来批量记账。这个功能直接对标真实需求答辩讲到“我从微信导出的账单能用这个系统批量识别”时评委的兴趣会明显不一样。注意导入时要做数据校验——金额格式、日期格式错了要跳过错行而不是整体失败。第三个加分项是图标筛选与搜索。目前账单页的分类筛选是下拉框改成图标列表后视觉上更“智能”。实现方式是给category表加一个icon字段存图标类名前端用el-tag或自定义图标列表渲染点击即触发筛选。这个改动的成本极低但演示效果比默认下拉框好很多。调试完这些功能后我的习惯是每次改动都强制走一遍完整流程清空数据库 → 执行初始化脚本 → 按启动顺序拉起前后端 → 跑一遍登录、新增、筛选、统计、导出、退出。全部通过才敢说这个版本可以拿去演示。希望这套源码能帮你把毕业设计的核心得分点吃透也希望这篇拆解能让你少踩几个我当时踩过的坑祝顺利。本文还有配套的精品资源点击获取