☰
SpringBoot+Vue3前后端分离实战:校园志愿者管理系统开发详解
2026/10/21 17:59:31 网站建设 项目流程

1. 项目概述——从一次校园招募活动聊起

有次在学校团委办公室帮忙处理志愿者招募,我真正体会到了什么叫"台账灾难"。一张几百人的报名表在群里传来传去,有人改了版本没同步,有人报了名但不知道自己被分到哪个岗位,活动结束要统计服务时长时,得从聊天记录和纸质签到表里一条条翻。那一刻我意识到,校园志愿者工作不是没有管理需求,而是缺少一个能让信息跑起来的系统。

这个项目就是围绕"校园志愿者管理系统"做的前后端分离实现。后端用 Java 和 SpringBoot 提供 API 服务,ORM 层选了 MyBatis 操作 MySQL 数据库,前端用 Vue3 配合 Vite 构建页面,整套东西部署起来之后,管理员能发布活动、审核报名、记录服务时长,志愿者能在小程序或网页端报名、查看活动安排、查自己的累计时长。系统的核心价值不是把纸质表格变成电子表格,而是把报名、审核、签到、统计这条链路串起来,让每个环节的状态变化有记录、可追溯。

文章适合两类人看:一类是正在做课程设计或毕业设计的同学,可以直接参考数据库设计和后端接口拆解;另一类是刚接触前后端分离开发、想搞明白 SpringBoot 和 Vue3 到底怎么配合写业务的初学者。我会把从数据库设计到前后端联调的完整链路拆开讲,包括我实际踩过的坑和最终采用的解决方式。

2. 技术选型分析——这套组合踩在了什么点上

2.1 为什么是 SpringBoot 而不是 SSM 或 Spring Cloud

老工程师看到 SpringBoot 可能觉得没什么新鲜的,但对校园项目来说,SpringBoot 的价值恰恰在于它帮你省掉了大量"重复造轮子"的工作。如果回到十年前用 SSM(Spring + SpringMVC + MyBatis),你至少得手动配置 DispatcherServlet、数据源、事务管理器,还得处理一堆 XML 文件。SpringBoot 通过自动配置和起步依赖把这些全部收敛了,写一个 Controller 接口从建类到跑通不过几分钟。

有同学问过我,要不要直接上 Spring Cloud Alibaba 微服务?我问他这个系统有几个业务模块,他说管理员和志愿者两个角色,也就三四十张表。我说这种规模上微服务,等于开着卡车去菜市场买菜——不是不能开,是毫无必要。单机 Tomcat 完全扛得住校园场景的并发量,微服务带来的服务注册发现、配置中心、链路追踪,对你目前的问题只增加复杂度没有收益。

2.2 MyBatis 和 MyBatis-Plus,我为什么没选后者

现在很多教程上来就用 MyBatis-Plus,因为 BaseMapper 直接把单表 CRUD 封装好了,确实省事。但我在这个项目里坚持用原生 MyBatis,原因有两个。

一是这个项目里有大量多表联查的统计需求,比如"统计每个志愿者本月的累计服务时长""查询某次活动中所有报名学生的院系分布"。这类 SQL 用 MyBatis-Plus 的 LambdaQueryWrapper 拼起来非常别扭,最后你还是得写 XML 里的自定义 SQL。既然如此,不如一开始就把 SQL 掌控在自己手里。

二是学习价值。用 MyBatis-Plus 你可能会忽略 resultMap、动态 SQL、一级二级缓存这些核心机制的细节,而这些恰恰是面试和实际业务里最常被问到的东西。原生 MyBatis 写起来确实繁琐一点,但它逼着你理解每条 SQL 是怎么执行的,参数是怎么映射的,这层理解在排查问题时非常值钱。

2.3 Vue3 + Vite 为什么替代了 Vue2 + Webpack

Vue3 的组合式 API(Composition API)刚出的时候争议很大,用习惯 Options API 的人觉得代码变得"散"了。但真在项目里跑起来,你会发现组合式 API 的优势特别明显:把一个业务功能的所有状态和逻辑收拢在一起,比如报名模块的 loading 状态、表单数据、提交函数全放在一起,维护时不用在 data、methods、watch 之间来回跳了。

构建工具选 Vite 也完全是体验降维打击。开发环境下它利用浏览器原生 ES Module 做按需编译,启动速度秒开级,热更新几乎无感。我之前在 Webpack 项目里改一行代码等四五秒钟刷新的经历,换到 Vite 之后彻底消失了。部署时 Vite 打包出来是纯静态文件,丢到 Nginx 里就能用,跟后端完全解耦。

MySQL 作为数据库没有悬念,校园系统用不上 Oracle 级别的重型能力,MySQL 8.0 在窗口函数(比如按月份滚动统计时长)、JSON 字段支持上都很好用,社区资料也丰富,遇到问题基本都能搜到解法。

3. 数据库设计——从业务实体到建表语句

3.1 核心实体关系梳理

校园志愿者管理系统的业务链条可以拆成几个核心实体:用户(user)、志愿者档案(volunteer_profile)、活动(activity)、活动报名(activity_registration)、服务时长记录(service_record)、公告(notice)、学院/组织(organization)。

如果画 ER 图,关系大概是这样的:用户表是登录认证的基础,通过 user_type 字段区分管理员和志愿者;志愿者档案表和用户表是一对一关系,存放学号、学院、专业、联系方式等扩展信息;活动和报名表是一对多关系,一个活动对应多条报名记录;服务时长记录表则连接志愿者和活动,每次签到签退产生一条记录,同时也是统计报表的数据源。

这里容易踩的坑是"报名记录"和"服务时长记录"的边界划分。一开始我设想用一张表搞定,报名通过后直接往上加签到时间、签退时间、时长字段。后来发现不行——一个志愿者报名了一个活动但没被录用,这条报名记录里就不该有时长字段;一个活动可能分多天进行,志愿者每天签到一次,一条报名记录对应多条时长记录。把两个概念拆开之后,数据模型才真正贴合实际业务。

3.2 数据库表 DDL 实战

直接给关键表的建表语句比空谈设计有用得多。这是我调试过很多遍的版本,字段和索引都按实际查询路径设置过。

-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '登录用户名', `password` varchar(255) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `user_type` tinyint NOT NULL DEFAULT '2' COMMENT '用户类型:1管理员,2志愿者', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1正常,0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

注意几个细节。密码字段长度我留了 255,因为 BCrypt 加密出来的字符串本身就 60 个字符左右,加上未来的算法升级余量,255 是稳妥的。username 建了唯一索引,这是登录查询的高频路径,必须走索引。

-- 活动表 CREATE TABLE `activity` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '活动ID', `title` varchar(100) NOT NULL COMMENT '活动名称', `description` text COMMENT '活动描述', `location` varchar(255) DEFAULT NULL COMMENT '活动地点', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `max_volunteers` int DEFAULT NULL COMMENT '招募人数上限', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0草稿,1招募中,2进行中,3已结束,4已取消', `creator_id` bigint NOT NULL COMMENT '创建人ID(管理员)', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_starttime` (`status`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='志愿活动表';

我给活动表的status和start_time建了联合索引。为什么?因为首页的活动列表最常见的查询是"筛选某个状态下(招募中)并且按开始时间排序(最先开始的排前面)",联合索引能同时覆盖过滤和排序。单独给 status 建索引效果会打折,因为 InnoDB 二级索引本身就包含主键,但联合索引能避免回表时额外排序。

-- 活动报名表 CREATE TABLE `activity_registration` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '报名ID', `activity_id` bigint NOT NULL COMMENT '活动ID', `user_id` bigint NOT NULL COMMENT '志愿者用户ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0待审核,1已通过,2已拒绝,3已取消', `apply_reason` varchar(500) DEFAULT NULL COMMENT '报名理由', `audit_remark` varchar(500) DEFAULT NULL COMMENT '审核备注', `audit_time` datetime DEFAULT NULL COMMENT '审核时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名表';

报名表这里的关键是那个唯一索引uk_activity_user。它从数据库层面杜绝了同一个用户对同一个活动的重复报名,这个约束比在 Service 层写 if 判断靠谱得多,并发场景下不会有漏网之鱼。

时长记录表的结构稍微特殊一点,除了基本字段外,需要一张独立的表记录签到签退时间差生成的服务时长:

-- 服务时长记录表 CREATE TABLE `service_record` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID', `user_id` bigint NOT NULL COMMENT '志愿者ID', `activity_id` bigint NOT NULL COMMENT '活动ID', `registration_id` bigint NOT NULL COMMENT '关联的报名记录ID', `check_in_time` datetime DEFAULT NULL COMMENT '签到时间', `check_out_time` datetime DEFAULT NULL COMMENT '签退时间', `hours` decimal(5,2) DEFAULT NULL COMMENT '服务时长(小时)', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0未签到,1已签到,2已签退', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_activity_id` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务时长记录表';

hours字段类型选了DECIMAL(5,2),最多能存 999.99 小时,对校园场景绰绰有余。这里不建议用 FLOAT,浮点数计算精度会在统计汇总时出问题,DECIMAL 是定点数,才能保证钱和时长这类数据的精度。

3.3 数据字典与状态字段的约定

状态字段我习惯用 TINYINT 而不是字符串,原因很简单:整数比较比字符串快,存储空间小,而且不会出现"已通过""通过""审核通过"这种同义不同值的数据脏乱问题。代价是需要维护一套数据字典,在代码里用常量类或者枚举类统一管理。

public class ActivityStatus { public static final int DRAFT = 0; // 草稿 public static final int RECRUITING = 1; // 招募中 public static final int ONGOING = 2; // 进行中 public static final int FINISHED = 3; // 已结束 public static final int CANCELED = 4; // 已取消 }

这个做法在团队协作中尤其有价值,新同学看到activity.getStatus() == ActivityStatus.RECRUITING心里就有数了,而不是面对一个魔法数字1去猜含义。

4. SpringBoot 后端核心实现细节

4.1 项目结构——按业务模块而非技术层分包

很多人初学 SpringBoot 喜欢用controller、service、mapper这种按技术层分包的方式,项目小的时候没毛病,一旦业务模块多起来就会乱,改一个活动相关功能要穿梭五六个包。我这次按业务领域分包,Alibaba 开发规范里叫"按业务垂直划分"。

com.campus.volunteer ├── common ← 通用工具、常量、统一响应体 ├── config ← 配置类(跨域、拦截器、WebMvc等) ├── security ← 登录认证、JWT工具 ├── module │ ├── auth ← 登录注册模块 │ ├── user ← 用户管理模块 │ ├── activity ← 活动管理模块 │ ├── registration← 报名审核模块 │ └── stats ← 时长统计模块

每个业务模块内按 controller、service、mapper、entity、dto、vo 分层。这样改一个模块的功能时,所有相关文件都在同一个包路径下,IDE 折叠起来一目了然。

4.2 登录认证与 JWT 无状态会话

校园系统不像企业系统那样有严格的企业微信或 OA 单点登录环境,JWT 认证是简单又够用的方案。流程是:用户提交用户名密码,后端校验通过后生成一个 HS256 签名的 JWT 返回给前端;前端把它存在本地存储里,每次请求在 Header 里带上Authorization: Bearer <token>;后端用拦截器解析 token 并注入用户信息到请求上下文。

核心拦截器实现:

@Component public class JwtInterceptor implements HandlerInterceptor { @Resource private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Long userId = jwtUtil.parseToken(token); // 将用户ID放入当前请求上下文,后续控制器直接读取 AuthContext.setUserId(userId); return true; } catch (Exception e) { // token过期或无效 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("{\"code\":401,\"msg\":\"登录状态过期,请重新登录\"}"); return false; } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }

这里有个实际开发中的细节:AuthContext底层是 ThreadLocal,用完必须清理,否则线程池复用线程时会串号。所以一定要在afterCompletion方法里调用AuthContext.clear()。

另一个安全要点是密码存储。项目中用 Spring Security Crypto 的 BCryptPasswordEncoder,它的特点是自动加盐,每次加密同一个密码得到的结果都不一样,但校验只需要一个方法调用:

@Resource private BCryptPasswordEncoder passwordEncoder; public String encodePassword(String rawPassword) { return passwordEncoder.encode(rawPassword); } public boolean matches(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); }

权限控制方面,我在拦截器基础上加了@RequireRole注解,配合一个简单的注解扫描判断,避免引入完整的 Spring Security 框架,因为这里只有管理员和志愿者两种角色,写十行代码的注解逻辑比引入整个 Security 体系划算得多。

4.3 MyBatis XML 映射中的动态 SQL

MyBatis 最强大的功能是动态 SQL,在 XML 里可以根据条件拼接查询语句。这里以活动条件查询为例,这个接口要支持按标题模糊搜索、按状态过滤、按时间段过滤、分页:

<select id="selectActivityPage" resultType="com.campus.volunteer.module.activity.vo.ActivityVO"> SELECT a.id, a.title, a.location, a.start_time, a.end_time, a.max_volunteers, a.status, (SELECT COUNT(*) FROM activity_registration r WHERE r.activity_id = a.id AND r.status = 1) AS registered_count, u.real_name AS creator_name FROM activity a LEFT JOIN user u ON a.creator_id = u.id <where> <if test="title != null and title.trim() != ''"> AND a.title LIKE CONCAT('%', #{title}, '%') </if> <if test="status != null"> AND a.status = #{status} </if> <if test="startTime != null"> AND a.start_time &gt;= #{startTime} </if> <if test="endTime != null"> AND a.end_time &lt;= #{endTime} </if> </where> ORDER BY a.create_time DESC LIMIT #{offset}, #{pageSize} </xml>

<where>标签的妙处在于它会自动去掉第一个条件前面的AND,这个老手都懂,但新手经常下午折腾半天发现 SQL 多出一个 AND 报错。子查询统计已报名人数也是常用手法,比在 Java 里循环查报名表高效得多。

分页这里用了手动LIMIT #{offset}, #{pageSize},没有引入 PageHelper 插件。我一直觉得 PageHelper 的物理分页通过拦截器改写 SQL 实现,遇到复杂嵌套查询时偶尔会报奇葩错误。项目查询量不大,手动 LIMIT 足够,并且 SQL 执行路径完全透明可控。

4.4 事务处理——报名和时长记录不能拆开

报名操作涉及两步:写入报名记录、更新活动当前报名人数(如果需要限制)。这两个操作要么一起成功要么一起失败,必须用事务包裹。SpringBoot 里的做法就是在 Service 方法上加@Transactional注解。

@Override @Transactional(rollbackFor = Exception.class) public void registerActivity(Long activityId, Long userId, String reason) { Activity activity = activityMapper.selectById(activityId); if (activity == null) { throw new BusinessException("活动不存在"); } if (activity.getStatus() != ActivityStatus.RECRUITING) { throw new BusinessException("该活动当前不可报名"); } ActivityRegistration registration = new ActivityRegistration(); registration.setActivityId(activityId); registration.setUserId(userId); registration.setStatus(RegistrationStatus.PENDING); registration.setApplyReason(reason); try { registrationMapper.insert(registration); } catch (DuplicateKeyException e) { throw new BusinessException("您已报名过该活动,请勿重复报名"); } }

注意两点。第一,rollbackFor = Exception.class必须写,因为 Spring 默认只对 RuntimeException 回滚,如果事务方法抛出 CheckedException(比如 IOException),数据不会回滚,这是个非常隐蔽的坑。第二,捕获了DuplicateKeyException并转成友好提示,这个异常就是前面那张报名表唯一索引在起作用时的表现。日志里保证不暴露 SQL 细节给前端,统一返回业务错消息。

签到签退逻辑也放在一个事务里:先在 service_record 表插入一条记录,状态置为"已签到",当签退时更新同一行记录将状态改为"已签退"并计算时长:

@Transactional(rollbackFor = Exception.class) public void checkOut(Long registrationId) { ServiceRecord record = serviceRecordMapper.selectByRegistrationId(registrationId); if (record == null || record.getStatus() != ServiceRecordStatus.CHECKED_IN) { throw new BusinessException("当前状态不允许签退"); } record.setCheckOutTime(new Date()); long diffMillis = record.getCheckOutTime().getTime() - record.getCheckInTime().getTime(); double hours = diffMillis / 3600000.0; // 保留两位小数 BigDecimal hoursDecimal = BigDecimal.valueOf(hours).setScale(2, RoundingMode.HALF_UP); record.setHours(hoursDecimal); record.setStatus(ServiceRecordStatus.CHECKED_OUT); serviceRecordMapper.updateById(record); }

时长计算这里有个业务导向的决定:我采用的是精确到秒的差值然后四舍五入到两位小数,而不是按分钟取整。如果按半小时为单位圆弧取整,有些活动时长 17 分钟,向上取整就是 0.5 小时,对学校统计不完全公平,四舍五入到两位小数更精确。

5. Vue3 前端工程化实践

5.1 创建项目与目录组织

前端我用 Vite 脚手架创建,命令很简单:

npm create vite@latest volunteer-web -- --template vue

创建完基础工程之后装核心依赖:

npm install vue-router@4 pinia axios element-plus

目录结构按"路由 + 视图 + 组件 + API + Store"组织:

src ├── api │ ├── activity.js │ ├── auth.js │ └── user.js ├── assets ├── components │ ├── ActivityForm.vue │ └── PaginationTable.vue ├── router │ └── index.js ├── stores │ └── user.js ├── views │ ├── login │ ├── admin │ │ ├── ActivityManage.vue │ │ ├── UserManage.vue │ │ └── StatsDashboard.vue │ └── volunteer │ ├── ActivityList.vue │ └── MyVolunteer.vue └── main.js

API 层是很多人容易忽略的。我要求自己前端所有请求都通过 api 目录下对应的模块发起,不要在组件里直接写 axios 请求字符串。这样后端接口路径改动时,只改 api 目录对应文件即可。

5.2 封装 Axios 拦截器——统一处理 token 和错误

Axios 拦截器是前端工程化的关键一步,在 request 拦截器里附加 token,在 response 拦截器里统一处理业务错误码:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:附加token request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) // 响应拦截器:统一处理业务码 request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } else if (res.code === 401) { const userStore = useUserStore() userStore.clearAuth() router.push('/login') return Promise.reject(new Error('未登录或登录过期')) } else { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )

一个很容易踩的细节:响应拦截器里不要直接把response返回,而是要返回response.data,否则每个业务组件里都要多写一层.data.data。这是我看到很多新手项目代码里最常见的冗余写法。

token 存储的位置也有讲究。我推荐存到 Pinia(状态管理)+ localStorage 双写:Pinia 保证页面内共享,localStorage 保证刷新后不丢登录态。但 localStorage 有 XSS 风险,在后端接口中要避免在回复里渲染不可信代码段,这是前后端共同防护的工作。

5.3 路由守卫——按角色动态拦截页面访问

前端路由必须做访问控制,否则普通志愿者直接访问/admin/users的前端页面就能看见管理界面。虽然后端接口有权限拦截,但前端不拦截的话用户体验很差,而且会暴露不必要的信息。

// router/index.js const routes = [ { path: '/login', component: () => import('@/views/login/LoginView.vue') }, { path: '/admin', component: () => import('@/layout/AdminLayout.vue'), meta: { roles: ['1'] }, children: [ { path: 'activities', component: () => import('@/views/admin/ActivityManage.vue') }, { path: 'users', component: () => import('@/views/admin/UserManage.vue') }, { path: 'stats', component: () => import('@/views/admin/StatsDashboard.vue') } ] }, { path: '/volunteer', component: () => import('@/layout/VolunteerLayout.vue'), meta: { roles: ['2'] }, children: [ { path: 'activities', component: () => import('@/views/volunteer/ActivityList.vue') }, { path: 'my-activities', component: () => import('@/views/volunteer/MyVolunteer.vue') } ] } ] router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.path === '/login') { next() return } if (!userStore.token) { next('/login') return } const requiredRoles = to.meta.roles if (requiredRoles && !requiredRoles.includes(userStore.userType)) { next('/403') return } next() })

这里用了meta.roles标记路由允许访问的角色,角色编码和数据库里user_type字段保持一致(1=管理员,2=志愿者),前后端一个标准。

5.4 典型页面实现——报名与数据可视化

活动列表页是整个系统前端最有代表性的页面,包含搜索条件区、分页表格、报名操作按钮。Vue3 组合式 API 的写法比 Options API 清晰太多:

<script setup> import { ref, onMounted } from 'vue' import { getActivityPage, applyActivity } from '@/api/activity' import { ElMessage, ElMessageBox } from 'element-plus' const queryParams = ref({ title: '', status: null, pageNum: 1, pageSize: 10 }) const tableData = ref([]) const total = ref(0) const loading = ref(false) async function fetchList() { loading.value = true try { const res = await getActivityPage(queryParams.value) tableData.value = res.data.records total.value = res.data.total } finally { loading.value = false } } function handleApply(row) { ElMessageBox.confirm(`确认报名参加"${row.title}"活动?`, '报名确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'info' }).then(async () => { await applyActivity(row.id, '暂无特殊说明') ElMessage.success('报名成功,请等待审核') fetchList() }).catch(() => {}) } onMounted(() => { fetchList() }) </script>

管理后台的统计页面用了 ECharts 展示按月的服务时长趋势和活动类型分布。Vue3 里用 ECharts 有两种方式,一种是封装成组件用ref绑定 DOM 初始化图表实例并在onBeforeUnmount时dispose销毁;另一种是直接用现成的 vue-echarts 封装库。校园项目数据量小,直接用 ECharts 原生 API 初始化即可,不需要再多引一层封装。

<template> <div ref="trendRef" style="height: 360px"></div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import * as echarts from 'echarts' const trendRef = ref(null) let chartInstance = null onMounted(() => { chartInstance = echarts.init(trendRef.value) chartInstance.setOption({ title: { text: '近6个月服务时长统计(小时)' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: [] }, yAxis: { type: 'value' }, series: [{ name: '服务时长', type: 'line', smooth: true, data: [] }] }) loadTrendData() }) async function loadTrendData() { const res = await getMonthlyHoursTrend() chartInstance.setOption({ xAxis: { data: res.data.map(i => i.month) }, series: [{ data: res.data.map(i => i.totalHours) }] }) } onBeforeUnmount(() => { chartInstance?.dispose() }) </script>

6. 前后端联调与常见坑排查

6.1 开发环境的跨域代理配置

前后端分离开发时,前端跑在 Vite 的 5173 端口,后端跑在 8080 端口,直接请求必然跨域。方案有后端加@CrossOrigin、后端配置全局跨域过滤器、前端配开发服务器代理。我强烈建议用第三种,因为前端代理方案在开发环境最方便,而且部署到生产环境时用 Nginx 反向代理也不需要后端额外处理。

Vite 配置文件里这样配:

// 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 前缀,需要重写路径 rewrite: path => path.replace(/^\/api/, '') } } } })

如果你后端 Controller 的 RequestMapping 里有/api前缀,那就不需要 rewrite。这个规范一定要前后端提前统一,比如后端接口全部以/api/xxx开头,前端 baseURL 设为/api,代理直接把/api原样转发到后端端口。

6.2 一个典型的接口字段类型不匹配问题

我得分享一个真实踩过的坑。后端返回的时间字段是LocalDateTime,经过 Jackson 序列化后默认格式是"2025-01-15T10:30:00",其中带一个字母 T。前端 Element Plus 的表格和时间选择器展示出来是 "2025-01-15T10:30:00",用户看着很不舒服,而且日期选择器回显时解析不了这个格式。

解决方式是统一在后端配置 Jackson 格式化:

# application.yml spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

这个配置对java.util.Date有效,但如果你用的是LocalDateTime或LocalDate,需要额外配置序列化器。一个更简洁的做法是在实体类的 LocalDateTime 字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解:

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startTime;

这个坑的教训是:前后端联调最先暴露的问题往往不是逻辑错误,而是数据格式约定不统一。所以项目起步开会时就要把时间格式、金额精度、状态码枚举值这些基础规范列出来签字画押,能省后面大量反复沟通的时间。

6.3 生产环境部署——Nginx 与 Jar 包

部署方案其实非常简单。后端项目用 Maven 打包:

mvn clean package -DskipTests

拿到target目录下的volunteer-server.jar,上传到 Linux 服务器,用:

nohup java -jar volunteer-server.jar --spring.profiles.active=prod > /var/log/volunteer.log 2>&1 &

前端构建:

npm run build

得到dist目录,上传到服务器的 Nginx 静态目录,配置 Nginx 把/api路径反向代理到本机的 8080 端口:

server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /var/www/volunteer-web/dist; index index.html; try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

try_files $uri $uri/ /index.html这行是 Vue Router history 模式必须的配置,否则刷新非首页路由时 Nginx 会去找不存在的资源路径,返回 404。

7. 实测体验与后续扩展方向

我把系统部署完后,模拟了完整的业务流程:创建活动、志愿者报名、审核通过、签到签退、查看统计。实测的感受是,一个完整闭环做下来后端接口 API 大约 30 个,前端页面 12 个,代码量中等偏上,一个人两三周完全可以完成,重点难点其实不在于写代码,而在于把注册、报名、审核、签到、统计这条数据链路的表结构设计清晰。

测试过程中发现的最大瓶颈在报名接口的并发取舍上。试想一个大型活动开放报名时,几百个学生同时提交,MySQL 的 InnoDB 行锁和唯一索引能保证不重复报名,但事务开启时间过长会拉高锁等待时间。我通过一个简单优化解决了:报名接口先做一次存在性查询(走唯一索引),存在则直接返回友好提示,不存在再走插入流程。这个查询动作能拦截掉绝大多数重复请求,把真正需要写库的请求量降到很低。不指望它能扛住电商级别的秒杀,但校园场景绰绰有余。

另一个值得说的点是系统上线后的数据治理。我在代码里给每个与金额、时长相关的 SQL 都做过一轮精度检查,避免Float累加误差在月度汇总报表中暴露出来。DECIMAL字段在 SUM 时也要注意转成 BigDecimal 再累加,小数位不同的字段之间直接相加也会出现精度丢失。

这个项目目前的架构还有很多可以扩展的方向。一是增加志愿者星级评定模块,根据服务时长和活动评价自动计算星级;二是接入微信小程序,让志愿者直接在小程序里接收活动推送和扫码签到,二维码里编码注册 ID,服务端扫码后自动完成绑定;三是引入活动反馈问卷,活动结束后给志愿者推送评价链接,数据统计分析服务于下一年的志愿活动规划。这些方向都会用到现有表和接口,架构上不需要推翻重来。

最后说一个运维上的小细节:MySQL 8.0 的默认认证插件是caching_sha2_password,如果你的连接 URL 没指定allowPublicKeyRetrieval=true,某些场景下连接池初始化会报错。这些细枝末节的东西不看官方文档很难发现,踩过一次记住了下次就顺了。开发中始终保持一个习惯:把异常堆栈从日志文件里捞出来读完整,而不是只看第一行就急着搜解决方案。很多"怪异问题"的根因,其实就藏在你不愿意往下看的那几行 Caused by 里。

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

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

立即咨询