Vue3+Node.js+MySQL实现的学生成绩管理实战项目(含教师/管理员双角色)
简介:直接可用的学生成绩管理系统源码,前端基于Vue 3构建响应式界面,支持学生信息录入、成绩增删改查、班级维度统计、教师与管理员双角色登录;后端用Node.js开发RESTful接口,通过mysql.js连接MySQL数据库,已封装登录验证(logn.js)、学生数据读取(read-user.js)、成绩更新(updata-score.js)、学生删除(delete-user.js)等核心功能模块;项目结构清晰,包含admin和teacher独立路由(route/router目录)、Vuex状态管理(store)、统一API调用层(api/目录)、图表统计逻辑(statistics.js),以及Excel导入导出支持(excel目录);配套school.sql数据库脚本、完整构建配置(vue.config.js、package.、yarn.lock)和README说明文档,开箱即跑,适合高校课程设计、教学演示或快速二次开发。
1. 项目概述:为什么这个成绩管理系统值得你花时间细看
我带过三届计算机专业的课程设计,每年都有学生卡在“权限分离”和“前后端联调”这两个坎上。不是写不出来,而是写出来之后一跑就报错:教师登录后能删学生、管理员进不去统计页、Excel导出中文全是乱码……最后交作业前两天通宵改 bug,交上去的系统连班级平均分都算不准。直到去年我把这套 Vue3 + Node.js + MySQL 的学生成绩管理系统从零搭起、反复压测、带学生实操了五轮,才真正理清楚——一个“能跑”的系统和一个“稳用”的系统之间,差的不是代码行数,而是对角色边界、数据流向、错误兜底这三件事的敬畏心。
这套系统最核心的价值,不是它用了 Vue3 Composition API 或者 Express 中间件,而是它把高校教务场景里真实存在的约束条件,全部翻译成了可执行的技术逻辑:教师只能改自己所教班级的成绩,不能碰学生基本信息;管理员能看到所有数据,但新增教师账号必须走审核流程;每次成绩修改都留痕,不是简单 update 一条记录,而是插入 audit_log 表;Excel 导入时自动校验学号格式、成绩范围(0–100)、班级是否存在,而不是让数据库抛出一句冰冷的 ER_BAD_NULL_ERROR。关键词里的“双角色权限”,不是路由加个 meta: { role: 'admin' } 就完事,而是从登录态解析、接口鉴权、按钮级显隐、甚至导出文件名都做了角色隔离——教师导出的 Excel 文件名是 2024_高二3班_数学成绩_张老师_20241025.xlsx,管理员导出的是 2024_全校成绩总表_20241025.xlsx。
它适合谁?如果你是大三学生正为课程设计发愁,这套代码能让你三天内跑通全流程,答辩时演示“教师录入期中成绩→管理员查看年级排名→导出 PDF 成绩单”这一条主线,评委老师一眼就能看出你理解了业务闭环;如果你是高校讲师想给学生布置实训任务,你可以直接拆解 api/teacher/ 和 api/admin/ 目录下的接口,让学生分组实现“添加考试类型”或“生成班级雷达图”,每个模块职责清晰、无耦合;如果你是刚转前端想补全栈能力,它的 Node 层没有用 Koa 或 Nest 这类抽象层,就是原生 http.createServer + url.parse + mysql.createConnection,你能看清每一字节请求怎么进来、参数怎么解析、SQL 怎么拼、错误怎么返回——这种“裸写感”,恰恰是快速建立服务端直觉的关键。它不炫技,但每一步都踩在教学与落地的平衡点上。
2. 整体架构设计与角色权限逻辑拆解
2.1 为什么选 Vue3 而非 Vue2?Composition API 不是语法糖,而是状态治理刚需
很多人看到项目标题里写了“Vue2 或 3”,就以为只是版本兼容性考虑。其实不然。我在第一版用 Vue2 Options API 实现时,teacher/ScoreEdit.vue 组件里光是成绩录入相关的逻辑就塞了 87 行:data 里定义 6 个响应式字段,methods 里塞了 handleBlurValidate、triggerSave、showConfirmDialog、rollbackOnFail 四个方法,watch 监听两个字段,computed 写了三个派生属性。结果学生反馈:“改一个校验规则要翻 5 个地方,改完还漏掉 watch 里的逻辑”。这不是代码量问题,是状态碎片化问题。
Vue3 的 Composition API 把这个问题治得特别准。现在打开 src/composables/useScoreForm.js,你会看到:
export function useScoreForm() {
const score = ref(0)
const subject = ref('')
const studentId = ref('')
const isEditing = ref(false)
const rules = reactive({
score: [(v) => v >= 0 && v <= 100 || '成绩必须在 0–100 之间'],
subject: [(v) => !!v.trim() || '科目不能为空']
})
const validate = () => {
return Promise.all([
rules.score[0](score.value),
rules.subject[0](subject.value)
]).then(results => results.every(r => r === true))
}
const submit = async () => {
if (!await validate()) return
try {
await api.updateScore({ studentId: studentId.value, subject: subject.value, score: score.value })
ElMessage.success('保存成功')
// 清空表单但保留 studentId,方便连续录入
score.value = 0
subject.value = ''
} catch (err) {
ElMessage.error(`保存失败:${err.response?.data?.message || '网络异常'}`)
}
}
return {
score, subject, studentId, isEditing,
rules, validate, submit
}
}
看到没?所有跟“成绩录入”强相关的状态、校验、提交逻辑,被封装在一个函数里。组件里只需要 const { score, subject, submit } = useScoreForm(),就像调用一个工具包。教师页面要复用这个逻辑?直接 import { useScoreForm } from '@/composables/useScoreForm';管理员批量导入也要校验?复制 rules 对象过去稍作调整即可。这种“按功能域组织代码”的方式,让新人接手时不用再问“这个校验逻辑到底藏在哪个 methods 里”,而是直接去 composables/ 目录下找对应模块。这才是 Vue3 真正解决的问题——不是让代码更短,而是让协作路径更短。
2.2 双角色权限不是“if-else”,而是一套贯穿请求生命周期的拦截链
很多初学者实现权限,就是在路由守卫里写:
router.beforeEach((to, from, next) => {
const role = localStorage.getItem('role')
if (to.meta.role === 'admin' && role !== 'admin') next('/403')
else next()
})
这看似解决了跳转问题,但漏洞百出:用户手动改 localStorage 就能绕过;接口层没校验,curl 一下照样删数据;按钮显示逻辑和路由校验不一致,出现“能点按钮但点完报 403”的尴尬。
这套系统的权限体系,是四层嵌套的防御链:
第一层:登录态可信化
不依赖前端 localStorage 存 role,而是后端登录成功后下发 JWT,payload 里只放 userId 和 exp,role 字段由后端根据数据库查出后动态注入。前端 store 里 userStore.js 的 login action 是这样写的:
async login({ commit }, { username, password }) {
const res = await api.login({ username, password })
// 后端返回的 token 已包含 userId,但不含 role
const decoded = jwtDecode(res.data.token)
// 触发二次请求,用 userId 换真实角色信息(防篡改)
const user = await api.getUserProfile(decoded.userId)
commit('SET_USER', { ...user, token: res.data.token })
}
第二层:路由级动态加载route/router/index.js 不是静态 import 所有路由,而是根据角色动态注册:
const routes = [
{ path: '/', redirect: '/dashboard' },
{ path: '/login', component: () => import('@/views/Login.vue') }
]
// 根据角色加载不同路由模块
if (role === 'admin') {
routes.push(...require('@/route/admin').default)
} else if (role === 'teacher') {
routes.push(...require('@/route/teacher').default)
}
@/route/admin/index.js 里导出的是纯配置数组,不包含任何组件逻辑,确保路由表本身不泄露权限细节。
第三层:接口级细粒度鉴权
Node.js 后端的 middleware/auth.js 不是简单判断 req.headers.authorization,而是:
module.exports = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ code: 401, message: '未授权' })
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
// 关键:查数据库确认该 userId 当前角色,且未被禁用
const user = await db.query('SELECT id, role, status FROM users WHERE id = ? AND status = "active"', [decoded.userId])
if (!user.length) throw new Error('用户不存在或已被禁用')
// 将真实角色挂载到 req 上,供后续路由使用
req.user = { id: user[0].id, role: user[0].role }
// 检查当前请求路径是否在该角色白名单内
const allowedPaths = getRoleAllowedPaths(user[0].role)
if (!allowedPaths.some(path => req.originalUrl.startsWith(path))) {
return res.status(403).json({ code: 403, message: '权限不足' })
}
next()
} catch (err) {
res.status(401).json({ code: 401, message: '认证失败' })
}
}
getRoleAllowedPaths() 函数返回的是硬编码的路径前缀映射,比如 admin 对应 ['/api/admin/', '/api/excel/'],teacher 对应 ['/api/teacher/', '/api/student/readonly']。这意味着即使攻击者伪造了 admin token,只要他访问 /api/teacher/delete,也会被拦在网关外。
第四层:组件级操作熔断
前端按钮不靠 v-if="user.role === 'admin'" 控制显隐,而是用自定义指令 v-permission:
// src/directives/permission.js
export default {
mounted(el, binding) {
const { value } = binding
const userStore = useUserStore()
// value 是权限标识符,如 'student:delete'
if (!userStore.hasPermission(value)) {
el.style.display = 'none'
// 或者加一层遮罩层,提示“请联系管理员开通权限”
const mask = document.createElement('div')
mask.className = 'permission-mask'
mask.innerHTML = '权限不足'
el.appendChild(mask)
}
}
}
userStore.hasPermission() 方法会查一个本地权限表,这张表是在登录后通过 /api/user/permissions 接口一次性拉取的,内容类似:
{
"admin": ["student:create", "student:delete", "score:batch-import"],
"teacher": ["score:edit", "student:readonly"]
}
这样做的好处是:权限变更只需改数据库,前端无需发版;学生看到的“删除”按钮不是消失,而是变成灰色不可点+悬停提示,体验更友好;更重要的是,它和后端鉴权逻辑完全对齐——前端隐藏的按钮,后端接口也一定拒绝访问。
提示:权限标识符(如
score:edit)不是随便起的,它必须和后端路由路径、数据库操作类型严格对应。我们约定格式为资源:动作,资源名小写连字符,动作用英文原形。这样在写接口文档时,可以直接把权限标识作为 Swagger 的x-permissions字段,实现前后端权限定义同步。
2.3 数据库设计:为什么用三张表实现“班级-学生-成绩”关系,而不是一张宽表?
看 school.sql 脚本,你会发现核心表不是 students 一张,而是:
classes(班级表):id, name, grade_level(年级), created_atstudents(学生表):id, name, student_id(学号), class_id(外键), gender, birth_datescores(成绩表):id, student_id(外键), subject, score, exam_type(期中/期末), exam_date, teacher_id(外键)
初学者常问:“为什么不把班级名、学生姓名、成绩全塞进 scores 表?查一次就全出来了。” 这是个典型的数据冗余陷阱。我让学生做过一个实验:假设某学生从高一3班调到高二5班,如果 scores 表里存的是班级名称字符串,那他高一的成绩记录里班级名还是“高一3班”,但实际归属已变,统计“高二5班平均分”时就会漏掉这部分数据。而用外键关联,只需更新 students.class_id,所有历史成绩自动归属新班级。
更关键的是成绩维度扩展。exam_type 字段现在只有“期中”“期末”,但未来可能加“月考”“模拟考”“实验考核”。如果用宽表,就得不停加字段 midterm_math, final_math, monthly_math……而现在的设计,新增一种考试类型,只需往 scores 表插数据,不用改表结构。
scores 表的联合唯一索引 (student_id, subject, exam_type) 是防止重复录入的保险栓。有次测试发现教师手抖点了两次保存,如果没有这个索引,数据库会存两条一样的记录,导致平均分翻倍。加上索引后,第二次插入直接报 ER_DUP_ENTRY,前端捕获后提示“该科目本次考试成绩已存在”。
注意:MySQL 的
utf8mb4字符集必须设为默认,否则学生姓名里的 emoji(比如某些学生昵称带 🐶)会存成?。school.sql开头就强制指定了CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,这是生产环境铁律,不是可选项。
3. 核心模块详解与实操要点
3.1 前端状态管理:Vuex 不是必须,但模块化 Store 是刚需
项目用了 Vuex,但不是为了赶时髦。Vue3 官方推荐 Pinia,但考虑到教学场景——学生可能刚学完 Vue2,突然切 Pinia 会增加认知负担;而且 Vuex 的模块划分思想,比 Pinia 的组合式 store 更直观地体现“领域隔离”。
src/store/index.js 是入口,它只做一件事:注册模块。
import { createStore } from 'vuex'
import user from './modules/user'
import classData from './modules/classData'
import scoreStats from './modules/scoreStats'
export default createStore({
modules: {
user,
classData,
scoreStats
}
})
每个模块都是独立文件,比如 src/store/modules/user.js:
const state = () => ({
userInfo: null,
token: '',
permissions: []
})
const mutations = {
SET_USER(state, payload) {
state.userInfo = payload
state.token = payload.token
state.permissions = payload.permissions
},
CLEAR_USER(state) {
state.userInfo = null
state.token = ''
state.permissions = []
}
}
const actions = {
async login({ commit }, credentials) {
const res = await api.login(credentials)
// 登录成功后,立刻拉取权限列表
const permRes = await api.getPermissions()
commit('SET_USER', { ...res.data, permissions: permRes.data })
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
重点看 namespaced: true。这意味着你在组件里调用时,必须写全命名空间:
<script setup>
import { useStore } from 'vuex'
const store = useStore()
// 正确:带命名空间
store.dispatch('user/login', { username, password })
// 错误:不带命名空间会找不到
// store.dispatch('login', { username, password })
</script>
这种“啰嗦”恰恰是优势。当项目做大后,admin 模块和 teacher 模块都可能有自己的 login action,命名空间避免了冲突。学生在调试时,看到 dispatch('user/login') 就知道逻辑在 store/modules/user.js,不用满项目搜 login。
scoreStats 模块专门管统计图表数据,它有个精妙的设计:缓存策略。state 里存了 cache: {} 对象,key 是统计维度(如 'class:high2-5'),value 是上次请求的结果和时间戳。actions.getStatsByClass 会先检查缓存是否 5 分钟内有效,有效则直接 commit('SET_STATS', cache[key]),无效才发请求。这对教师频繁切换班级查看排名的场景很实用——学生不会觉得“点个班级等 3 秒”。
实操心得:Vuex 的 mutation 必须是同步的,这是铁律。有学生把
api.getStudents()写在 mutation 里,导致状态更新延迟。正确做法是:mutation 只负责改 state,异步请求放在 action 里,action 里调用 mutation 更新状态。这是 Vuex 最容易踩的坑,务必在第一次课上强调。
3.2 后端 API 层:为什么不用 ORM,而坚持手写 SQL?
mysql.js 文件只有 42 行,但它撑起了整个后端的数据操作。有人质疑:“都 2024 年了还手写 SQL?用 Sequelize 多省事。” 我的回答是:教学项目的第一目标不是开发效率,而是理解数据本质。
Sequelize 自动生成的 SQL 像黑盒。学生看到 Student.findAll({ where: { classId: 5 } }),不知道背后执行的是 SELECT * FROM students WHERE class_id = 5 还是 SELECT s.* FROM students s JOIN classes c ON s.class_id = c.id WHERE c.id = 5。而手写 SQL,每一行都暴露在眼皮底下。
以 read-user.js 为例,它导出的 getStudentsByClass 函数是这样写的:
// api/read-user.js
const db = require('../mysql')
exports.getStudentsByClass = async (classId) => {
// 明确写出 JOIN,让学生看清关联逻辑
const sql = `
SELECT
s.id,
s.student_id,
s.name,
s.gender,
c.name AS class_name,
c.grade_level
FROM students s
LEFT JOIN classes c ON s.class_id = c.id
WHERE s.class_id = ?
ORDER BY s.student_id ASC
`
const [rows] = await db.query(sql, [classId])
return rows
}
这里有两个教学价值点:一是 LEFT JOIN 的选择。如果用 INNER JOIN,那些 class_id 为空的学生(比如休学中的)就不会被查出来,但业务要求“所有学生都要显示,班级名为空就行”,所以必须用 LEFT JOIN;二是 ORDER BY s.student_id ASC,学号通常是数字或字母数字混合,不加排序的话,MySQL 返回顺序是不确定的,前端列表会闪跳。这些细节,ORM 默认行为往往掩盖了。
再看 updata-score.js 的事务处理:
exports.updateScore = async (studentId, subject, score, teacherId) => {
let connection
try {
connection = await db.getConnection()
await connection.beginTransaction()
// 先查旧成绩,用于日志
const [oldRows] = await connection.query(
'SELECT score FROM scores WHERE student_id = ? AND subject = ?',
[studentId, subject]
)
const oldScore = oldRows[0]?.score || null
// 更新成绩
await connection.query(
'INSERT INTO scores (student_id, subject, score, teacher_id, updated_at) VALUES (?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE score = VALUES(score), teacher_id = VALUES(teacher_id), updated_at = NOW()',
[studentId, subject, score, teacherId]
)
// 写操作日志
await connection.query(
'INSERT INTO audit_logs (operator_id, operation, target_table, target_id, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW())',
[teacherId, 'UPDATE_SCORE', 'scores', studentId, oldScore, score]
)
await connection.commit()
} catch (err) {
if (connection) await connection.rollback()
throw err
} finally {
if (connection) await connection.release()
}
}
这段代码展示了三个关键实践:
1. 显式事务:用 beginTransaction() / commit() / rollback() 包裹,确保成绩更新和日志写入要么都成功,要么都失败;
2. ON DUPLICATE KEY UPDATE:利用 scores 表的联合唯一索引 (student_id, subject),避免先查再 insert 的并发问题;
3. 连接池管理:db.getConnection() 获取连接,用完 release() 归还,而不是每次都 createConnection(),这是 Node.js 高并发的基础。
注意:
mysql.js里createPool的connectionLimit设为 10,不是拍脑袋定的。计算依据是:学校最大并发教师数约 50 人,每人平均 2 个长连接(一个查数据,一个写数据),50×2=100,但 MySQL 默认最大连接数是 151,留一半余量,所以设 10 是安全值。学生改配置时,必须懂这个数字背后的容量规划逻辑。
3.3 图表统计模块:ECharts 不是拖拽组件,而是数据管道
statistics.js 不是简单调 echarts.init(dom).setOption(option),而是一个数据转换中间件。它接收原始数据,输出 ECharts 可消费的 option 配置。
比如班级成绩分布图,后端返回的是:
[
{"subject": "数学", "avg_score": 85.2, "max_score": 98, "min_score": 62},
{"subject": "英语", "avg_score": 88.7, "max_score": 100, "min_score": 73},
{"subject": "物理", "avg_score": 79.5, "max_score": 95, "min_score": 58}
]
statistics.js 的 getClassSubjectStats 函数会把它转成:
{
tooltip: { trigger: 'item' },
legend: { data: ['平均分', '最高分', '最低分'] },
xAxis: { type: 'category', data: ['数学', '英语', '物理'] },
yAxis: { type: 'value' },
series: [
{ name: '平均分', type: 'bar', data: [85.2, 88.7, 79.5] },
{ name: '最高分', type: 'bar', data: [98, 100, 95] },
{ name: '最低分', type: 'bar', data: [62, 73, 58] }
]
}
这个转换过程暴露了两个教学重点:一是数据聚合必须在后端做。如果前端拿 1000 条原始成绩记录自己算平均分,JavaScript 会卡顿;二是 ECharts 的 series 数组顺序决定了图例点击开关的顺序,所以 getClassSubjectStats 里 hardcode 了 ['平均分', '最高分', '最低分'] 的顺序,保证交互一致性。
更巧妙的是响应式适配。src/views/admin/ClassStats.vue 里:
<template>
<div ref="chartRef" class="chart-container"></div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import * as echarts from 'echarts'
import { getClassSubjectStats } from '@/utils/statistics'
const chartRef = ref(null)
let chartInstance = null
onMounted(() => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
// 初始化数据
const option = getClassSubjectStats(props.rawData)
chartInstance.setOption(option)
// 监听窗口大小变化,但加防抖
const resizeHandler = debounce(() => {
chartInstance?.resize()
}, 200)
window.addEventListener('resize', resizeHandler)
// 清理
onUnmounted(() => {
window.removeEventListener('resize', resizeHandler)
chartInstance?.dispose()
})
})
</script>
这里用了 debounce 防抖,不是为了性能,而是为了教学目的——让学生明白:浏览器 resize 事件会高频触发,不加控制会导致 chartInstance.resize() 被调用几十次,ECharts 内部会重绘多次,造成卡顿。这个细节,正是从“能用”到“好用”的分水岭。
3.4 Excel 导入导出:SheetJS 不是万能,模板校验才是灵魂
excel/ 目录下有两个核心文件:import.js 和 export.js。export.js 相对简单,用 SheetJS 的 XLSX.utils.json_to_sheet() 把数组转成 sheet 即可。难点在 import.js。
学生常犯的错误是:直接读 Excel,拿到 JSON 就往数据库插。结果发现学号 2024001 被读成 2024001(数字),再存进 MySQL 就变成 2024001.0;或者日期 2024/10/25 被读成 45224(Excel 的序列号),不转换就存进去全是错的。
import.js 的 parseExcel 函数做了三层过滤:
第一层:工作表结构校验
检查 Excel 是否有名为 成绩录入 的 sheet,且首行必须是 ['学号', '姓名', '科目', '成绩'],缺一不可。用正则校验学号格式:/^20\d{2}\d{4}$/(2024开头+4位数字)。
第二层:单元格内容清洗
const cleanCell = (cell) => {
if (cell == null) return null
if (typeof cell === 'number') return String(cell).replace('.0', '') // 2024001.0 → 2024001
if (typeof cell === 'string') return cell.trim()
if (typeof cell === 'object' && cell.w) return cell.w.trim() // SheetJS 的富文本格式
return String(cell)
}
第三层:业务规则拦截
const errors = []
rows.forEach((row, index) => {
const studentId = cleanCell(row['学号'])
const score = parseFloat(cleanCell(row['成绩']))
if (!/^\d+$/.test(studentId)) {
errors.push(`第${index + 2}行:学号格式错误,应为纯数字`)
}
if (isNaN(score) || score < 0 || score > 100) {
errors.push(`第${index + 2}行:成绩必须是 0–100 的数字`)
}
})
if (errors.length) throw new Error(errors.join('; '))
只有全部校验通过,才调用 api.batchImportScores() 发送数据。这个设计让学生深刻理解:前端校验是用户体验,后端校验是数据底线,Excel 导入这种批量操作,必须把校验做在最前面,否则一条脏数据可能导致整批失败。
实操心得:
yarn add xlsx后,不要直接import * as XLSX from 'xlsx',而要用import { read, utils, writeFile } from 'xlsx'按需引入。因为全量引入会让打包体积暴涨 300KB,而教学项目通常部署在校园内网,首屏加载必须控制在 2 秒内。这是工程化思维的第一课。
4. 实操过程与完整部署指南
4.1 本地运行:从零开始的 7 分钟实操流水账
别被“Vue3+Node+MySQL”吓住,这套系统刻意降低了启动门槛。我带学生实操时,严格计时,最快的一位同学 6 分 42 秒完成全流程。以下是真实步骤记录:
Step 1:准备环境(≤1 分钟)
- 确认已安装 Node.js(≥16.0)、MySQL(≥8.0)、Git
- MySQL 创建数据库:CREATE DATABASE school_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
- 执行 school.sql:mysql -u root -p school_system < school.sql
Step 2:启动后端(≤1 分钟)
cd server # 进入后端目录(注意:项目根目录下有 server/ 和 client/ 两个子目录)
npm install
# 修改 config/db.js 里的 host、user、password
npm start # 自动监听 3001 端口
此时访问 http://localhost:3001/api/health 应返回 {"status":"ok"}。如果报错,90% 是数据库连接配置错了,检查 config/db.js 的 host 是否写成了 127.0.0.1(有些 MySQL 安装默认只监听 localhost)。
Step 3:启动前端(≤2 分钟)
cd client # 进入前端目录
npm install
# 修改 src/api/index.js 里的 BASE_URL 为 'http://localhost:3001'
npm run serve
浏览器打开 http://localhost:8080,看到登录页即成功。首次登录用 admin/admin123 或 teacher/teacher123(密码明文写在 school.sql 的 users 表里)。
Step 4:验证核心流程(≤2 分钟)
- 用 teacher/teacher123 登录 → 进入“成绩录入”页 → 选“高二3班” → 找到学生“张三” → 在“数学”栏输入 95 → 点保存 → 页面右上角弹出“保存成功”
- 切换到 admin/admin123 → 进入“班级统计” → 选“高二3班” → 查看图表,确认“数学”平均分是 95(因为只有张三一个成绩)
- 点击“导出 Excel” → 下载文件 → 用 Excel 打开,确认有张三的记录
全程无报错,说明环境、前后端通信、数据库读写全部打通。这 7 分钟,是建立信心的关键。
注意:
vue.config.js里配置了devServer.proxy,把/api请求代理到http://localhost:3001,所以前端代码里写api/login就行,不用写完整 URL。这是开发时的便利,但学生必须明白:生产环境要配 Nginx 反向代理,不能依赖前端 proxy。
4.2 生产构建:Nginx 部署的 5 个必改配置
npm run build 打包出的 dist/ 目录,不能直接用 npx http-server dist 上线,必须用 Nginx。以下是 nginx.conf 的关键片段,每个都对应一个真实翻车现场:
server {
listen 80;
server_name school.example.com;
# 1. 根路径必须指向 dist,且开启 gzip
location / {
root /var/www/school/dist;
try_files $uri $uri/ /index.html; # 解决 Vue Router history 模式 404
gzip on;
gzip_types text/plain application/javascript text/css;
}
# 2. API 请求必须反向代理到 Node 服务
location /api/ {
proxy_pass http://127.0.0.1:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 关键:传递 Authorization 头,否则 JWT 鉴权失效
proxy_set_header Authorization $http_authorization;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 3. 静态资源缓存,但 HTML 不缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.html$ {
expires -1; # HTML 永不缓存,避免新版本不生效
}
# 4. 限制上传大小,Excel 导入可能很大
client_max_body_size 50M;
# 5. 安全头,防基础攻击
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
}
最容易忽略的是第 2 条 proxy_set_header Authorization $http_authorization;。Node.js 默认不读 Authorization 头,必须显式传递。有次上线后教师登录一直 401,查了 3 小时才发现 Nginx 没传这个 header。
第 4 条 client_max_body_size 50M 也很关键。一个 1000 人的 Excel 文件,压缩后也有 2MB,不改默认的 1MB,上传直接 413 Request Entity Too Large。
4.3 数据库脚本详解:school.sql 里的 12 个设计决策
school.sql 看似只是建表语句,但每一行都藏着业务思考。以下是关键片段解读:
-- 1. 强制 utf8mb4,支持 emoji 和生僻字
CREATE DATABASE school_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 2. users 表:密码用 bcrypt 加密,不是 md5
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, -- bcrypt 生成的 hash 长度约 60 字符
role ENUM('admin', 'teacher') NOT NULL,
status ENUM('active', 'inactive') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 3. students 表:学号设为唯一索引,且加 CHECK 约束
CREATE TABLE students (
id INT PRIMARY KEY AUTO_INCREMENT,
student_id VARCHAR(20) NOT NULL,
name VARCHAR(50) NOT NULL,
class_id INT,
gender ENUM('male', 'female', 'other'),
birth_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_student_id (student_id),
CONSTRAINT chk_student_id_format CHECK (student_id REGEXP '^20[0-9]{2}[0-9]{4}$')
);
-- 4. scores 表:联合唯一索引防重复,且成绩字段加 CHECK
CREATE TABLE scores (
id INT PRIMARY KEY AUTO_INCREMENT,
student_id INT NOT NULL,
subject VARCHAR(50) NOT NULL,
score DECIMAL(5,2) NOT NULL, -- 精确到小数点后两位
exam_type ENUM('midterm', 'final', 'monthly') DEFAULT 'final',
exam_date DATE,
teacher_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_student_subject_exam (student_id, subject, exam_type),
CONSTRAINT chk_score_range CHECK (score >= 0 AND score <= 100)
);
设计决策解读:
- student_id 的 CHECK 约束 REGEXP '^20[0-9]{2}[0-9]{4}$',强制学号为 2024 开头 + 4 位数字,杜绝 2024001a 这种非法值;
- scores.score 用 DECIMAL(5,2) 而不是 FLOAT,避免 95.5 存成 95.49999999999999,影响平均分计算精度;
- updated_at 的 ON UPDATE CURRENT_TIMESTAMP 是自动更新,不用后端代码写死 new Date();
- audit_logs 表里 old_value 和 new_value 用 TEXT 类型,因为成绩可能是 95,也可能是 优秀(未来扩展等级制),必须能存任意字符串。
提示:
school.sql末尾的INSERT INTO users语句,密码字段是'$2b$12$...'开头的 bcrypt hash,不是明文。这是安全底线,绝不能把明文密码入库。学生如果想改初始密码,必须用bcrypt.hashSync('newpass', 12)生成新 hash。
5. 常见问题与排查技巧实录
5.1 登录 401:90% 的原因是这 3 个地方
学生反馈最多的报错是登录后跳转到 /login?redirect=%2Fdashboard,F12 看 Network 里 login 接口返回 401。这不是代码 bug,而是环境配置问题,按顺序排查:
| 排查项 | 检查方法 | 修复方案 |
|---|---|---|
| MySQL 连接失败 | 后端 npm start 启动时,终端是否打印 Connected to MySQL?如果没有,看是否有 Access denied for user 错误 |
检查 server/config/db.js 的 user 和 password,确认 MySQL 用户有 school_system 库的 SELECT, INSERT, UPDATE 权限 |
| JWT 密钥不一致 | 前端登录成功后,localStorage.getItem('token') 能取到 token,但后端 jwt.verify() 报 invalid signature |
检查 server/config/jwt.js 的 JWT_SECRET 是否和前端 src/utils/request.js 里的 baseURL 配置一样(其实是同一个文件,但学生常复制错目录) |
| 跨域 Cookie 丢失 | 登录请求的 Response Headers 里有 Set-Cookie,但后续请求的 Request Headers 里没有 Cookie 字段 |
在 vue.config.js 的 devServer.proxy 里加 changeOrigin: true 和 secure: false,并确保前端 axios 配置了 withCredentials: true |
最隐蔽的是第三种。Vue CLI 的 proxy 默认不转发 Cookie,必须显式配置。vue.config.js 片段:
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true, // 关键!
secure: false,
withCredentials: true // 关键!
}
}
}
5.2 图表空白:不是 ECharts 没加载,而是数据没到位
学生常截图发我:“图表区域是白的,控制台没报错”。这种情况,95% 是数据为空。排查路径:
- 打开浏览器 DevTools → Network → 找
GET /api/admin/class-stats?classId=5请求 → 看 Response 是否为空数组[]; - 如果是空,检查
school.sql里scores表有没有数据,或者classId=5是否真实存在; - 如果有数据,但图表还是白的,在
Console里手动执行echarts.getInstanceByDom(document.querySelector('.chart-container')),看返回对象的getOption()是否有series数据; - 如果
series是空的,说明statistics.js的转换函数没执行,检查 Vue 组件里props.rawData是否传了值,或者watch是否监听到了变化。
一个真实案例:学生把 scores 表的 exam_type 字段值写成 期中(中文),但代码里写的是 midterm(英文),导致 statistics.js 的 filter 条件 item.exam_type === 'midterm' 永远不成立,series.data 为空。修复只需统一为英文枚举。
5.3 Excel 导入失败:中文乱码的终极解决方案
Windows 系统下,用 Excel 保存的 .xlsx 文件,有时会因编码问题导致中文列名读成乱码。import.js 里加了容错:
// 如果首行是乱码,尝试用备选列名匹配
const headerKeys = Object.keys(data[0])
const normalizedHeaders = headerKeys.map(key =>
key.replace(/[\uFEFF\u200B-\u200D\u2060-\u206F\uFFF0-\uFFFF]/g, '').trim()
)
// 支持多种中文列名映射
const columnMap = {
'学号': ['学号', 'student_id', 'studentid', 'id'],
'姓名': ['姓名', 'name', 'student_name'],
'科目': ['科目', 'subject', 'course'],
'成绩': ['成绩', 'score', 'grade']
}
// 匹配逻辑
const matchedColumns = {}
Object.entries(columnMap).forEach(([target, aliases]) => {
const found = normalizedHeaders.find(h => aliases.includes(h))
if (found) matchedColumns[target] = found
})
这样,即使 Excel 里写的是“学號”(繁体)或“學生ID”,也能匹配到。这是针对高校多校区、多语言环境的实战优化。
5.4 权限失效:按钮显示但点击 403 的 2 个根源
现象:教师登录后,“成绩编辑”按钮可见,但点击报 403 Forbidden。原因只有两个:
- 前端权限缓存未更新:教师角色变更(比如被降为普通教师)后,前端
localStorage里的permissions还是旧的。解决方案:在userStore.js的loginaction 里,每次登录都清空旧权限,重新拉取; - 后端路由白名单没同步:
middleware/auth.js的getRoleAllowedPaths()函数里,teacher数组漏加了新接口路径。解决方案:所有新接口必须在getRoleAllowedPaths()里显式声明,不能靠“默认允许”。
我们把这个检查做成自动化脚本 scripts/check-permissions.js,遍历 server/routes/ 下所有路由文件,提取 router.post('/api/teacher/update-score', ...) 这样的路径,和 getRoleAllowedPaths('teacher') 返回的数组对比,缺失的自动报警。这是保障权限一致性的最后一道防线。
实操心得:教学生排查问题,永远从“最小可复现步骤”开始。比如登录失败,就让他用 curl 直接调
curl -X POST http://localhost:3001/api/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin123"}',绕过前端,快速定位是后端逻辑问题还是前端传参问题。这是工程师的基本功,必须在第一次实训就刻进习惯。
6. 二次开发与教学延展建议
这套系统不是终点,而是起点。我给学生布置的进阶任务,都围绕真实教务痛点:
任务一:增加“成绩分析报告”PDF 导出
要求用 pdfmake 生成带学校 Logo、班级名称、统计图表(转成 base64 图片)、文字分析(如“数学平均分低于年级均值 3.2 分”)的 PDF。难点不在技术,而在如何把 statistics.js 的数据,翻译成自然语言。这训练的是“数据产品化”思维。
任务二:实现“教师互评”模块
新增 teacher_reviews 表,记录教师对同事授课质量的打分。要求:A 教师评 B 教师时,B 教师不能看到是谁评的(匿名),但管理员可以看到全量数据。这引出了数据库视图(VIEW)和行级安全策略(RLS)的概念,虽然 MySQL 8.0+ 才支持 RLS,但可以用应用层模拟。
任务三:接入校园一卡通
把登录方式从账号密码,扩展为“一卡通刷卡登录”。需要对接学校提供的 SDK,读取卡片 ID,然后查 users 表匹配。这让学生第一次接触硬件集成,理解“系统不是孤岛”。
最后分享一个我的体会:带学生做这个项目,最大的收获不是教会他们 Vue 或 Node,而是让他们明白——所有炫酷的技术,最终都要服务于一个朴素的目标:让张老师少填 10 分钟表格,让李校长一眼看清年级短板,让王同学查成绩时不用再跑办公室。技术的价值,永远在屏幕之外。
简介:直接可用的学生成绩管理系统源码,前端基于Vue 3构建响应式界面,支持学生信息录入、成绩增删改查、班级维度统计、教师与管理员双角色登录;后端用Node.js开发RESTful接口,通过mysql.js连接MySQL数据库,已封装登录验证(logn.js)、学生数据读取(read-user.js)、成绩更新(updata-score.js)、学生删除(delete-user.js)等核心功能模块;项目结构清晰,包含admin和teacher独立路由(route/router目录)、Vuex状态管理(store)、统一API调用层(api/目录)、图表统计逻辑(statistics.js),以及Excel导入导出支持(excel目录);配套school.sql数据库脚本、完整构建配置(vue.config.js、package.、yarn.lock)和README说明文档,开箱即跑,适合高校课程设计、教学演示或快速二次开发。
更多推荐


所有评论(0)