每年学期末学校的教务部门往往要为评教这件事忙掉一层皮。过去很多学校还在用Excel收集、人工催办、VLOOKUP算平均分那一套学生要么被安排到机房集中填报要么教务员逐个导出数据再按公式汇总。流程长、错误多最麻烦的是无法做到“对谁保密、对谁公开”的权限控制——学生写了哪个老师的评语不该被第三人看到但Excel文件传来传去谁也说不准有没有外泄。我参与过两所高校的评教系统改造今天这篇笔记聊的就是一套基于Spring Boot Vue的高校学生评教系统。它不是大而全的教务中台而是专门解决“评教”这一件核心事的独立应用学生登录后看到自己本学期要评的课程逐门打分、填写意见教师端查看自己的评分结果和匿名评语教务处和院系管理员负责创建评教任务、设置指标权重、统计汇总和导出报表。如果你正在学Spring Boot和Vue想找个练手的完整前后端分离项目或者工作中恰好接手了教学评估这类需求那这篇文章的内容应该能帮你少走不少弯路。1. 先理清评教系统的业务闭环再聊Spring Boot和Vue怎么分工很多初学者拿到“评教系统”这个需求第一反应就是建几张表、写几个增删改查接口然后开始堆页面。这么做的结果往往是做到一半发现规则对不上——谁能评谁、什么时候能评、提交之后能不能改、分数怎么加权……全是坑。所以我会先把业务闭环拆开讲明白技术选型反而放在后面。1.1 四类用户四种不同的工作台评教系统不是只有“学生评老师”这一件事。按角色划分至少有四类用户学生登录后只能看到本学期与自己相关的评教任务也就是“我需要评哪些课程”逐门提交。已提交的评教记录不能再修改更不能重复提交。教师查看自己课程的评分均分、分项得分、排名区间和匿名评语。教师看不到具体是哪个学生写的评语也不允许看到其他老师的详细结果。院系管理员管理本院系的课程、师生归属创建本院的评教任务配置指标权重查看本院排名。教务处/超级管理员发布评教通知、全局配置、查看全校汇总、导出报表必要时可以开放补评通道。这四类用户对应了系统的核心权限模型。前端要做成四个不同的“工作台”后端接口也要按角色做数据隔离。很多项目把教师和管理员界面混在一起做最后权限判断写得非常痛苦。我建议一开始就按角色建不同的路由和页面结构。1.2 评教任务、问卷指标与权重是业务的三个核心评教业务可以拆成三层第一层是任务。一次评教周期就是一个任务通常包含学期名称、开始时间、结束时间、任务状态。比如“2024-2025学年第一学期学生评教”从12月1日开始到12月31日截止状态是“进行中”。第二层是任务下的课程和评教范围。不是所有学生评所有课程而是“选课关系”决定了评教范围。比如小张选了王老师的《高等数学》那么这次评教任务里小张就有一条待评记录。如果小张这学期没选李老师的课那他就不该看到李老师的评教表单。第三层是问卷与权重。课程问卷通常按指标维度拆分常见三类教学态度、教学能力、教学效果。每个维度下还有若干道题比如“备课是否充分”“课堂互动是否有效”“作业批改是否及时”。教务处可以给每个维度设置权重例如教学态度占20%、教学能力占50%、教学效果占30%。权重这个点特别重要因为最终总分不是把所有题目分数简单求平均而是要按维度归一化后再加权。很多第一次做评教系统的人看到“算出平均分”就以为完成了需求结果被业务老师拿着规则一问才发现完全对不上。1.3 为什么选Spring Boot Vue这套组合评教系统的核心场景是“集中式填报”这意味着开发周期不能太长、部署不能太重同时又要能扛住评教高峰期学生同时登录提交的压力。Spring Boot Vue在今天依然是一套非常务实的选择。Spring Boot负责后端业务和接口。它把Spring生态里那些繁琐的配置做成了自动配置内嵌Tomcat打包成jar以后直接java -jar就能启动不需要单独部署Servlet容器。配合Spring Security和JWT做登录鉴权再配合MyBatis或MyBatis-Plus操作数据库开发效率很高。对我来说Spring Boot最大的吸引力不是技术多新而是生态成熟碰到任何问题几乎都能搜到案例不会让项目卡在环境层面。Vue负责前端交互。学生评教页面的核心是问卷表单题目列表、评分单选按钮、评语输入框。用Vue的组件化能力这些可以被拆成多个可复用组件维护起来比原来的JSP模板舒服得多。加上Element Plus这套现成的UI组件库表格、表单、弹出确认框、分页都能直接拿来用页面开发效率能提升一大截。前后端分离还有一个额外的好处教务导出报表、教师查看趋势图表这些场景未来可以平滑扩展到其他终端后端接口可以直接复用。虽然目前系统只在PC浏览器上跑但接口设计得干净后续要做移动端也不会伤筋动骨。2. 后端骨架搭建数据模型、登录鉴权与评教提交业务规则理清楚之后后端开发就有方向了。我不会把整个项目的代码贴出来而是重点讲设计思路和数据模型因为评教系统真正的复杂度不在增删改查而在“数据怎么组织”和“提交时怎么防错”。2.1 数据库表设计十张表一次建明白评教系统的表可以分成四组用户权限组、基础数据组、评教配置组、评教结果组。用户权限组sys_user用户表字段包括id、username、password、real_name、dept_id等。password保存的是BCrypt加密后的值。sys_role角色表比如STUDENT、TEACHER、DEPT_ADMIN、ADMIN。sys_user_role用户角色关联表一个用户可以有多个角色。sys_dept院系表便于按院系做数据隔离。基础数据组t_course课程表字段包括course_name、course_code、teacher_id、dept_id等。t_student_course选课关系表保存哪个学生选了哪门课。评教范围最终由这张表推导。评教配置组t_eval_task评教任务表字段包括term、task_name、start_time、end_time、status。t_eval_question问卷题目表保存题目内容、所属指标维度、分值。题目可以做成可配置也可以按任务复制一份快照防止历史任务被后来的修改影响。t_eval_weight指标权重表保存教学态度、教学能力、教学效果各占多少比例。评教结果组t_eval_answer评教答卷表每一行表示某个学生对某门课程某道题的打分。字段包括eval_task_id、course_id、student_id、question_id、score、comment。t_eval_submit评教提交记录表每个学生针对一门课程的正式提交记录用于幂等判断。这样设计的好处是职责单一。t_eval_answer存的是“明细数据”后续要做任何维度的统计都能从明细里算出来t_eval_submit只负责“是否已提交”这个状态避免在大量明细数据上做count判断是否重复提交。2.2 Spring Security JWT登录鉴权怎么做才不绕学生评教系统对权限的要求很简单但也严格学生不能看到别人的评教结果教师不能看到其他老师的数据管理员不能越权操作其他院系。建议使用Spring Security配合JWT实现无状态登录流程图上一眼就能看懂用户提交用户名、密码到/api/auth/login。后端校验密码通过后生成JWT响应给前端。前端把JWT存到localStorage并在后续请求的Header里带上Authorization: Bearer token。后端的OncePerRequestFilter拦截请求解析Token并设置安全上下文。关键代码很简单核心是一个过滤器Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; public JwtAuthenticationFilter(JwtUtil jwtUtil) { this.jwtUtil jwtUtil; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); String username jwtUtil.extractUsername(token); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } }在SecurityConfig里把登录接口和静态资源接口放进白名单其余接口一律需要认证。角色控制用PreAuthorize(hasRole(TEACHER))这类注解就行比到处写if判断清爽得多。2.3 评教提交接口的事务与幂等控制评教提交是系统里最核心的写接口调用频率高业务约束也多。接口接收的数据至少包含这三部分评教任务ID、课程ID、题目答案列表和总评语。提交时后端必须做三轮校验校验评教时间。当前时间必须在任务开始时间和结束时间之间早于或晚于都不允许提交。校验评教范围。当前学生确实选了这个老师的课才能对它评教。校验重复提交。同一个学生对同一门课程只能提交一次。我建议把这三轮校验放在一个事务里配合数据库唯一索引兜底。表的逻辑大概是CREATE TABLE t_eval_submit ( id BIGINT PRIMARY KEY AUTO_INCREMENT, eval_task_id BIGINT NOT NULL, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, submit_time DATETIME NOT NULL, UNIQUE KEY uk_task_course_student (eval_task_id, course_id, student_id) ) ENGINEInnoDB;Service层的方法签名和逻辑如下这是简化版但能看明白事务和校验的组合方式Override Transactional(rollbackFor Exception.class) public void submitEvaluation(EvalSubmitDTO dto) { Long studentId SecurityUtils.getCurrentUserId(); // 1. 校验评教时间 EvalTask task evalTaskMapper.selectById(dto.getEvalTaskId()); LocalDateTime now LocalDateTime.now(); if (now.isBefore(task.getStartTime()) || now.isAfter(task.getEndTime())) { throw new BizException(当前不在评教时间段内); } // 2. 校验评教范围 if (studentCourseMapper.selectCount(studentId, dto.getCourseId()) 0) { throw new BizException(您不在该课程的评教范围内); } // 3. 校验并插入提交记录 EvalSubmit submit new EvalSubmit(); submit.setEvalTaskId(dto.getEvalTaskId()); submit.setCourseId(dto.getCourseId()); submit.setStudentId(studentId); submit.setSubmitTime(now); try { evalSubmitMapper.insert(submit); } catch (DuplicateKeyException e) { throw new BizException(该课程已提交过评教请勿重复操作); } // 4. 明细表批量插入 dto.getAnswers().forEach(answer - { EvalAnswer record new EvalAnswer(); record.setEvalTaskId(dto.getEvalTaskId()); record.setCourseId(dto.getCourseId()); record.setStudentId(studentId); record.setQuestionId(answer.getQuestionId()); record.setScore(answer.getScore()); record.setComment(dto.getComment()); evalAnswerMapper.insert(record); }); }唯一索引加Transactional是我在这个项目里最满意的一处设计。即使前端没做按钮禁用、即使两个请求同时到达数据库也会拦住重复提交不会出现“学生评了两次、成绩被算了两次”的脏数据。2.4 评教任务自动截止的定时任务评教任务有明确的开始和结束时间教务不可能半夜手动把状态改成“已结束”。这里用Spring Boot的定时任务就能解决。在启动类加上EnableScheduling然后写一个关闭过期任务的组件Component public class EvalTaskScheduler { private final EvalTaskMapper evalTaskMapper; public EvalTaskScheduler(EvalTaskMapper evalTaskMapper) { this.evalTaskMapper evalTaskMapper; } Scheduled(cron 0 0 2 * * ?) public void closeExpiredTasks() { evalTaskMapper.updateStatusByEndTime(LocalDateTime.now()); } }注意单机Scheduled够用但如果部署了多个后端实例定时任务会在每台机器上同时执行。量小的场景问题不大量大的时候建议引入分布式锁或者直接用更成熟的任务调度框架避免重复更新同一批数据。3. 前端Vue实战从环境搭建到动态路由再到评教表单组件前端我用的是Vue3 Vite Element Plus PiniaVue Router负责路由。下面这些内容是我实际搭项目时的操作记录给你做个参考。3.1 初始化项目Vue3配Vite还是用Webpack现在新项目我基本都推荐Vue3 Vite启动速度快、依赖安装简单。如果你用的是IDEA开发建议直接在命令行操作不要用IDEA自带的模板版本容易踩坑。初始化命令npm create vitelatest eval-frontend -- --template vue cd eval-frontend npm install然后安装需要的依赖npm install vue-router4 pinia axios element-plus装完之后按模块建目录src/api放接口请求src/router放路由配置src/store放Pinia状态src/views放页面src/components放公共组件。这个目录结构很简单但足够清晰后续扩展不会乱。3.2 路由守卫和基于角色的动态路由学生、教师、管理员看到的页面完全不一样。我不想把所有页面都堆在导航菜单里再靠v-if判断显示哪个而是希望“不在当前角色权限内的页面根本进不去”。这里用Vue Router的动态路由就能实现。基础思路是登录成功后后端返回当前用户的角色和可访问的菜单列表前端把这些信息存到Pinia里。路由守卫每次跳转前检查如果没登录且访问的不是登录页则跳转到/login。如果已经登录且访问登录页则跳转到首页。如果当前路由配置了meta.roles则判断用户角色是否匹配。路由守卫的典型实现router.beforeEach((to, from, next) { const authStore useAuthStore(); const token authStore.token; if (!token to.path ! /login) { next(/login); return; } if (token to.path /login) { next(/); return; } if (token to.meta.roles !to.meta.roles.includes(authStore.roleCode)) { next(/403); return; } next(); });实际项目里我把教师端路由和学生端路由拆成了两个模块管理员的任务管理、院系管理、用户管理单独一个模块。每个模块的meta.roles写明确比如const adminRoutes { path: /admin, component: () import(/views/admin/Layout.vue), meta: { roles: [ADMIN, DEPT_ADMIN] }, children: [ { path: task, component: () import(/views/admin/EvalTask.vue), meta: { title: 评教任务管理 } }, { path: weight, component: () import(/views/admin/WeightConfig.vue), meta: { title: 指标权重设置 } }, { path: result, component: () import(/views/admin/EvalResult.vue), meta: { title: 汇总报表 } }, ] };这样每个角色登录后看到的菜单和能访问的路由是天然隔离的不会出现学生打开教师端页面再被接口鉴权弹出一堆错误的情况。3.3 评教问卷页面的组件化设计评教页面是整个系统使用频率最高的页面做得顺手不顺手直接关系到学生愿不愿意认真填。我把它拆成了三个组件EvalTaskList.vue展示待评课程列表每张卡片显示课程名、教师名、当前状态以及实时进度。EvalForm.vue评教表单容器负责加载问卷题目、校验填答完整度、提交数据。QuestionItem.vue单道题目的渲染组件根据题目类型动态渲染单选、多选、文本输入框。打分题我用的是Element Plus的el-rate或el-radio-group。这里有一个细节分数值在后端设计成1到5分。展示的时候如果直接用数字会显得生硬可以给每一档配一个描述比如“5分非常满意4分满意3分一般2分不满意1分非常不满意”。有了描述学生打分的时候会更稳定不会因为理解不一致导致评分标准漂移。提交前必须校验完整度。做法是遍历当前页面的题目检查每道题是否有值有漏答就弹提示并定位到第一个未答项。代码大致是这样const submit async () { const unanswered questions.value.filter(q !answers.value[q.id]); if (unanswered.length 0) { ElMessage.warning(还有 ${unanswered.length} 道题未评分请检查后提交); return; } await evalApi.submit({ taskId, courseId, answers: answers.value, comment }); };还有一个容易被忽略的点学生提交完一门课后列表页要立刻把该课程标记为“已评”并在侧边显示剩余待评数量。这个状态我放在Pinia里维护提交成功后调接口重新拉取待评列表保证多页面切换时数据一致。3.4 接口封装与状态管理前端直接在每个页面里写axios请求会非常难受尤其是Token失效要统一跳登录、接口报错要统一弹提示这些公共逻辑。我习惯在src/api里建一个request实例import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000, }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token); window.location.href /login; } return Promise.reject(error); } );然后把每个后端接口按模块封装成函数比如src/api/eval.js里导出getTaskList、getQuestions、submitEvaluation。页面里只调用这些函数完全不感知axios细节。这样做的收益在项目后期非常明显改接口地址、加统一参数都只需要动一个文件。4. 评教汇总、权重计算与结果可视化评教数据在学生提交那一刻开始才真正产生价值。教务和老师最关心的是“某某老师这学期评分怎么样”“和上学期比是涨是降”“哪个维度拉了后腿”。这一章讲统计与展示也是系统里最容易算错账的地方。4.1 权重计算别把平均分直接当总分评教系统的分项指标体系通常是这样问卷分为教学态度、教学能力、教学效果三类每类各若干道题每道题满分5分。假设教学态度有4道题、教学能力有6道题、教学效果有4道题某个老师收到的有效答卷是200份。怎么算这个老师的总分第一步算每个维度的平均分。把该维度下所有学生所有题目的得分求和再除以题目总数和学生总数。第二步把维度平均分乘以对应权重。假设权重配置是教学态度20%、教学能力50%、教学效果30%那么指标维度平均分权重加权得分教学态度4.220%0.84教学能力4.550%2.25教学效果4.030%1.20总分4.29加权得分的计算逻辑要写清楚否则教务处追问“为什么平均分4.2总分却显示4.29”的时候解释起来很费劲。公式是总分 Σ(维度平均分 × 维度权重)SQL可以这样查某个任务下每个教师的维度平均分SELECT teacher_id, dimension, AVG(score) AS avg_score FROM t_eval_answer WHERE eval_task_id ? GROUP BY teacher_id, dimension然后再在Java内存里做权重乘法最后按总分排序。这个方案比试图用一大段SQL直接算出排名要清晰得多而且调试方便。4.2 教师端报表ECharts展示历年趋势和维度雷达图教师端不应该只给一个冷冰冰的总分。我做了三个展示模块当前任务的总分和排名区间。老师只需要知道自己在全校或本院的大致位置不需要精确排名。做法是后端算完排名后返回该教师排名和人数的比值比如“前15%”。维度得分雷达图。用ECharts的雷达图展示教学态度、教学能力、教学效果这三个维度上的得分教师一眼就能看出自己的短板。历年趋势折线图。按学期展示总分变化方便教师复盘。ECharts的引入很简单使用echarts库关键是后端提供的接口数据格式要稳定。我返回的JSON结构类似{ termList: [2023-2024-1, 2023-2024-2, 2024-2025-1], scoreList: [4.12, 4.28, 4.35], dimensionData: { 教学态度: [4.0, 4.3, 4.4], 教学能力: [4.1, 4.2, 4.3], 教学效果: [4.0, 4.3, 4.3] } }前端拿到数据后按固定格式装配进ECharts的option代码非常机械但稳定性很重要别在接口里塞一堆字段名风格不一致的key否则前端的适配工作会把你折磨到怀疑人生。4.3 管理员汇总页面明细导出与数据透视管理员最终需要的是“能拿去开会”的报表。我提供了两个操作按院系统计的汇总表和明细表导出。汇总表展示每个院系、每名教师的得分支持按排名排序。明细表则是所有答卷的原始数据教务可以用来做抽样复核。导出功能后端用EasyExcel实现前端点击按钮后请求接口下载文件流const exportExcel async () { const response await request.get(/api/admin/eval/export, { params: filters, responseType: blob }); const blob new Blob([response.data]); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download 评教汇总.xlsx; link.click(); };这里有一个安全细节导出的Excel里如果包含学生姓名、学号、IP地址这类个人信息要脱敏。学号中间四位用*代替学生姓名只显示姓和“同学”。别小看这一步很多学校的信息安全检查会把报表外泄当成重大事故来处理。5. 联调、部署与上线前必须避开的坑最后这部分是我真实踩过的坑比前面的设计问题更实在。5.1 开发环境跨域Vite代理与后端点对点配置前后端分离开发最常见的问题是“前端页面访问端口5173后端接口在8080跨域了怎么办”。两种处理方式我更推荐开发时用Vite代理生产时用Nginx反向代理。Vite的配置写法// vite.config.js export default { server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };这样前端代码里请求/api/eval/task/listVite开发服务器会自动转发到http://localhost:8080/api/...浏览器层面不存在跨域问题。如果你的后端加了Spring Security记得放行/api/auth/login等白名单接口否则代理配上也会收到401。生产环境用Nginx配置写在后端服务的上一层server { listen 80; server_name eval.example.edu.cn; root /var/www/eval/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }location /里的try_files必须带否则Vue Router使用history模式时刷新非首页地址会返回404。这是新手最容易踩的坑没有之一。5.2 数据库时区与JSON日期格式评教任务有时间段统计报表里有提交时间稍微搞错时区就会出现“任务明明是今天截止却提前一天显示已结束”的诡异问题。反正我在上线前被这个问题坑过。MySQL连接串里务必加serverTimezoneAsia/Shanghai比如spring: datasource: url: jdbc:mysql://localhost:3306/eval_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai同时Spring Boot返回给前端的LocalDateTime默认格式是ISO格式比如2025-01-15T10:30:00前端直接用不太友好。我建议统一配置成yyyy-MM-dd HH:mm:ssspring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8后端也不要混用Date和LocalDateTime。项目里如果两种类型混着用序列化和比较时的坑会连环爆。我现在的做法是全项目统一用LocalDateTime数据库字段统一用datetime类型逻辑清爽不少。5.3 打包构建与部署脚本后端部署是常规操作mvn clean package -DskipTests java -jar target/eval-system.jar --spring.profiles.activeprod前端部署npm run buildVite会生成dist目录把dist里的静态文件复制到Nginx的/var/www/eval/dist下就行。更新部署时要注意前端文件更新后Nginx不需要重启浏览器会自动加载新资源但如果使用了带hash的静态文件名还要留意CDN缓存问题没有CDN的话基本不用担心。5.4 上线前一定要检查的三件事第一数据库迁移脚本必须走版本控制。评教系统会经历多次需求和规则调整比如权重从固定值变成可配置、题目从固定变成按任务快照。如果靠手工改数据库早晚出事故。我建议引入Flyway管理数据库结构变更。第二敏感信息的访问日志要过滤。学生评教属于教学管理信息评教明细最好不打印到应用日志里。我上线前把日志配置里的org.springframework.web调成了WARN级别避免请求参数中暴露学生ID和题目答案。第三高并发场景要提前做压测。学生评教通常集中在截止前两三天登录、提交接口的瞬时流量不小。用JMeter模拟个几百并发打一下/api/auth/login和/api/eval/submit看看响应时间是否可接受连接池数量是否够用。如果数据库连接池默认的10条不够可以调大到30或者50但也要考虑数据库上限。最后说一点个人体会。评教系统这类“小而明确”的校园应用其实是最适合拿来练手完整前后端项目的题材。麻雀虽小五脏俱全有权限、有任务状态机、有问卷渲染、有统计计算、有批量导出还有典型的联调部署问题。我在把系统交给教务处试运行的那个学期最经常收到的反馈不是“界面不好看”而是“导出Excel能不能按分位数自动标色”“能不能让班长看到班级提交进度”。这类需求听着小但每次都能带出一轮新的设计思考。如果你也在做类似的系统希望这篇笔记里的经验能帮你提前躲开我踩过的那些坑。