本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接可用的学生成绩管理系统源码,前端基于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 里塞了 handleBlurValidatetriggerSaveshowConfirmDialogrollbackOnFail 四个方法,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 里只放 userIdexp,role 字段由后端根据数据库查出后动态注入。前端 store 里 userStore.jslogin 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_at
  • students(学生表):id, name, student_id(学号), class_id(外键), gender, birth_date
  • scores(成绩表):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.jscreatePoolconnectionLimit 设为 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.jsgetClassSubjectStats 函数会把它转成:

{
  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.jsexport.jsexport.js 相对简单,用 SheetJS 的 XLSX.utils.json_to_sheet() 把数组转成 sheet 即可。难点在 import.js

学生常犯的错误是:直接读 Excel,拿到 JSON 就往数据库插。结果发现学号 2024001 被读成 2024001(数字),再存进 MySQL 就变成 2024001.0;或者日期 2024/10/25 被读成 45224(Excel 的序列号),不转换就存进去全是错的。

import.jsparseExcel 函数做了三层过滤:

第一层:工作表结构校验
检查 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.sqlmysql -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.jshost 是否写成了 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/admin123teacher/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_idCHECK 约束 REGEXP '^20[0-9]{2}[0-9]{4}$',强制学号为 2024 开头 + 4 位数字,杜绝 2024001a 这种非法值;
- scores.scoreDECIMAL(5,2) 而不是 FLOAT,避免 95.5 存成 95.49999999999999,影响平均分计算精度;
- updated_atON UPDATE CURRENT_TIMESTAMP 是自动更新,不用后端代码写死 new Date()
- audit_logs 表里 old_valuenew_valueTEXT 类型,因为成绩可能是 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.jsuserpassword,确认 MySQL 用户有 school_system 库的 SELECT, INSERT, UPDATE 权限
JWT 密钥不一致 前端登录成功后,localStorage.getItem('token') 能取到 token,但后端 jwt.verify()invalid signature 检查 server/config/jwt.jsJWT_SECRET 是否和前端 src/utils/request.js 里的 baseURL 配置一样(其实是同一个文件,但学生常复制错目录)
跨域 Cookie 丢失 登录请求的 Response Headers 里有 Set-Cookie,但后续请求的 Request Headers 里没有 Cookie 字段 vue.config.jsdevServer.proxy 里加 changeOrigin: truesecure: 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% 是数据为空。排查路径:

  1. 打开浏览器 DevTools → Network → 找 GET /api/admin/class-stats?classId=5 请求 → 看 Response 是否为空数组 []
  2. 如果是空,检查 school.sqlscores 表有没有数据,或者 classId=5 是否真实存在;
  3. 如果有数据,但图表还是白的,在 Console 里手动执行 echarts.getInstanceByDom(document.querySelector('.chart-container')),看返回对象的 getOption() 是否有 series 数据;
  4. 如果 series 是空的,说明 statistics.js 的转换函数没执行,检查 Vue 组件里 props.rawData 是否传了值,或者 watch 是否监听到了变化。

一个真实案例:学生把 scores 表的 exam_type 字段值写成 期中(中文),但代码里写的是 midterm(英文),导致 statistics.jsfilter 条件 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。原因只有两个:

  1. 前端权限缓存未更新:教师角色变更(比如被降为普通教师)后,前端 localStorage 里的 permissions 还是旧的。解决方案:在 userStore.jslogin action 里,每次登录都清空旧权限,重新拉取;
  2. 后端路由白名单没同步middleware/auth.jsgetRoleAllowedPaths() 函数里,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 分钟表格,让李校长一眼看清年级短板,让王同学查成绩时不用再跑办公室。技术的价值,永远在屏幕之外。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接可用的学生成绩管理系统源码,前端基于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说明文档,开箱即跑,适合高校课程设计、教学演示或快速二次开发。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐