
1. 项目概述这套系统到底做了什么1.1 核心需求解析最近工作中频繁收到学生和初入职场的Python开发者的咨询有没有一套拿得出手的成绩管理系统能做课程设计或者毕业设计这其实是一个非常典型的需求——前端用Vue3后端用Flask数据操作走RESTful API页面效果要现代代码结构要清晰。于是我整理了这套基于Python Flask Vue3的前后端分离学生成绩管理系统。这套系统的核心能力包括学生信息的增删改查支持按学号、姓名、班级、专业等条件筛选成绩的录入、批量导入Excel、修改与删除支持多门课程独立管理自动计算总分、平均分、班级排名、年级排名标记不及格科目可视化统计分析班级平均分对比、分数段分布、课程难度对比采用JWT做简单的登录鉴权区分管理员与普通教师角色适用场景非常明确如果你正在做课程设计、毕业设计或者需要一个中小型教务管理系统的参考实现这套代码可以直接参考复现。技术栈覆盖面足够广但又不会像大型微服务项目那样复杂到劝退初学者。1.2 为什么功能定位要小而完整我见过很多学生项目一上来就设计十几张表权限模型、消息通知、批量审批全都上最后代码写到一半写不下去。我的实践经验是做一个业务系统第一版只需要覆盖一条完整的主业务链路就够了。这套系统锁定的主链路是学生信息维护 → 成绩录入 → 成绩查询与统计 → 异常成绩标记 → 可视化展示。用户角色只有管理员和普通教师两种权限粒度做到教师只能录入自己所授课程的成绩这个级别既体现专业度又不至于失控。功能定级我列过一个表功能模块定位说明学生信息管理基础必备班级、专业、入学年份等字段成绩录入与导入核心必备支持Excel批量导入单条录入成绩统计加分项平均分、及格率、排名自动计算可视化报表加分项ECharts分数段分布、课程对比登录鉴权安全必备JWT 密码哈希存储优不优质不在于功能多而在于每个功能是否完整可用、代码是否可读、部署是否顺畅。这套系统花在把基础功能做扎实上的精力远多于堆新功能。2. 技术选型与整体架构设计2.1 为什么是Flask而不是Django或FastAPI后端选用Flask是我权衡后的决定。Flask最大的优势是轻量、灵活、生态成熟对于这种典型CRUD项目来说它的代码组织非常直观蓝图划分模块、SQLAlchemy管理模型、JWT扩展处理鉴权每个环节都有足够多的成熟方案。相比之下Django自带了Admin后台、ORM、中间件等大量组件但这套系统的数据模型比较单纯用Django反而显得笨重。FastAPI性能好、自动生成Swagger文档但生态和资料丰富度目前仍然不及Flask学生在排查问题时能搜到的Flask案例明显更多。2.2 前端为什么锁定Vue3前端使用Vue3 Vite Element Plus ECharts这套组合在2024到2025年已经非常成熟。Vue3的组合式APIComposition API让逻辑复用比Vue2的Options API清晰得多比如成绩表格的筛选逻辑、统计图表的加载逻辑都可以拆成独立的组合式函数。构建工具选择Vite而不是Webpack核心原因是本地开发体验差距太大。Vite冷启动几乎即时热更新毫秒级响应而且默认支持ESM和TypeScript。对于调试前后端联调的场景Vite的代理配置也比Webpack简单很多。2.3 架构图与数据流向整个系统是典型的前后端分离结构浏览器Vue3 Element Plus ECharts ↓ HTTP请求Axios携带JWT Token Flask 应用蓝图注册auth、students、scores、stats ↓ SQLAlchemy ORM SQLite 数据库开发环境 / MySQL生产可选前端通过Axios发起请求请求头携带Authorization: Bearer token。后端使用Flask-JWT-Extended解析Token校验身份和权限后处理业务逻辑操作数据库并返回JSON。前端拿到JSON后渲染表格或图表。开发环境下Vite会把前端请求代理到http://localhost:5000从而规避跨域。生产环境下Flask可以直接托管dist目录里构建出来的静态资源这样整个系统只需跑一个Flask进程部署成本非常低。2.4 目录结构规划项目按照后端优先、前端独立的思路组织student-score-system/ ├── backend/ │ ├── app/ │ │ ├── __init__.py # 创建Flask应用注册蓝图 │ │ ├── models.py # SQLAlchemy 模型 │ │ ├── auth.py # 登录、JWT、鉴权 │ │ ├── students.py # 学生管理接口 │ │ ├── scores.py # 成绩管理接口 │ │ ├── stats.py # 统计接口 │ │ └── utils.py # 通用工具函数 │ ├── requirements.txt │ └── run.py ├── frontend/ │ ├── src/ │ │ ├── api/ # Axios请求封装 │ │ ├── views/ # 页面组件 │ │ ├── router/ # Vue Router配置 │ │ └── store/ # Pinia状态管理 │ ├── vite.config.js │ └── package.json └── README.md这个结构本质上是把Flask官方文档推荐的工厂模式与Vue3脚手架默认结构拼在一起好处是任何人接手都能快速定位代码。我在做项目规划时有个习惯先画好目录再写代码这一步能省下后面大量重构时间。3. 数据库设计与后端核心实现3.1 数据表结构设计数据模型是整个系统的地基。这套系统共设计了四张核心表逻辑非常清晰# backend/app/models.py from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash db SQLAlchemy() class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(50), uniqueTrue, nullableFalse) password_hash db.Column(db.String(255), nullableFalse) role db.Column(db.String(20), defaultteacher) # admin / teacher def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) class Student(db.Model): __tablename__ students id db.Column(db.Integer, primary_keyTrue) student_no db.Column(db.String(20), uniqueTrue, nullableFalse) name db.Column(db.String(50), nullableFalse) gender db.Column(db.String(10)) class_name db.Column(db.String(50), nullableFalse) major db.Column(db.String(50)) enrollment_year db.Column(db.Integer) scores db.relationship(Score, backrefstudent, lazyTrue) class Course(db.Model): __tablename__ courses id db.Column(db.Integer, primary_keyTrue) course_name db.Column(db.String(50), uniqueTrue, nullableFalse) credit db.Column(db.Float, default2.0) scores db.relationship(Score, backrefcourse, lazyTrue) class Score(db.Model): __tablename__ scores id db.Column(db.Integer, primary_keyTrue) student_id db.Column(db.Integer, db.ForeignKey(students.id), nullableFalse) course_id db.Column(db.Integer, db.ForeignKey(courses.id), nullableFalse) score db.Column(db.Float, nullableFalse) exam_date db.Column(db.Date) __table_args__ (db.UniqueConstraint(student_id, course_id, nameuniq_student_course),)学生和课程是多对多关系通过成绩表关联。成绩表设置联合唯一约束避免同一学生同一门课重复录入这个约束是很多新手项目容易漏掉的。3.2 Flask应用工厂模式后端入口采用Flask工厂模式把创建应用的过程封装成函数好处是方便测试和部署# backend/app/__init__.py def create_app(): app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] sqlite:///score.db app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False app.config[SECRET_KEY] dev-secret-key-change-in-production app.config[JWT_SECRET_KEY] jwt-secret-key-change-in-production db.init_app(app) jwt JWTManager(app) app.register_blueprint(auth_bp, url_prefix/api/auth) app.register_blueprint(student_bp, url_prefix/api/students) app.register_blueprint(score_bp, url_prefix/api/scores) app.register_blueprint(stats_bp, url_prefix/api/stats) with app.app_context(): db.create_all() init_admin_user() return appdb.create_all()在开发阶段完全够用。但注意如果后续要改表结构直接调用这个方法不会自动迁移字段最好引入Flask-Migrate管理迁移。我在项目里就用它处理过一次给成绩表加考试日期字段的变更。3.3 学生管理接口实现学生接口是最典型的CRUD但有几个细节值得注意。比如分页、关键词搜索、班级筛选需要组合起来支持# backend/app/students.py student_bp.get(/) jwt_required() def get_students(): page request.args.get(page, 1, typeint) per_page request.args.get(per_page, 10, typeint) keyword request.args.get(keyword, , typestr) class_name request.args.get(class_name, , typestr) query Student.query if keyword: query query.filter( db.or_( Student.name.contains(keyword), Student.student_no.contains(keyword) ) ) if class_name: query query.filter(Student.class_name class_name) pagination query.order_by(Student.student_no).paginate( pagepage, per_pageper_page, error_outFalse ) result { items: [to_dict(s) for s in pagination.items], total: pagination.total, page: page, per_page: per_page, } return jsonify(result)这里的关键是jwt_required()装饰器它能拦截未登录请求。而per_page、page全部从查询字符串读取前端可以直接绑定Element Plus的el-pagination组件。3.4 成绩录入与统计计算成绩模块的核心不只是增删改查统计逻辑才是加分项。我实现了一个专门的计算函数# backend/app/scores.py 节选 def calculate_score_stats(records): if not records: return { total: 0, avg: 0, max: 0, min: 0, pass_count: 0, fail_count: 0, pass_rate: 0 } total len(records) avg sum(r.score for r in records) / total max_score max(r.score for r in records) min_score min(r.score for r in records) pass_count len([r for r in records if r.score 60]) fail_count total - pass_count pass_rate round(pass_count / total * 100, 2) return { total: total, avg: round(avg, 2), max: max_score, min: min_score, pass_count: pass_count, fail_count: fail_count, pass_rate: pass_rate }分数段分布统计也值得说一下。传统做法是Python里循环判断但我在实践里更喜欢在SQL里一次性算出来from sqlalchemy import func, case segment_result db.session.query( case( (Score.score 60, 不及格), (Score.score 70, 及格), (Score.score 80, 中等), (Score.score 90, 良好), else_优秀 ).label(segment), func.count(Score.id) ).group_by(segment).all()这样前端拿到的数据直接就是分段统计结果不需要在前端做二次计算。凡是能在数据库完成的聚合计算就不要丢给前端。3.5 Excel批量导入导出Excel处理是成绩管理系统的一个隐藏刚需。教师手动一条条录成绩非常痛苦而Excel导入导出能极大提升效率。实现思路是后端用pandas读取上传的Excel文件逐行校验后插入数据库import pandas as pd def import_scores_from_excel(file_storage, course_id): df pd.read_excel(file_storage) required_cols [学号, 成绩] for col in required_cols: if col not in df.columns: raise ValueError(fExcel缺少必要列: {col}) imported 0 errors [] for idx, row in df.iterrows(): student_no str(row[学号]).strip() score_val row[成绩] student Student.query.filter_by(student_nostudent_no).first() if not student: errors.append(f第{idx 2}行: 学号{student_no}不存在) continue if not isinstance(score_val, (int, float)): errors.append(f第{idx 2}行: 成绩格式错误) continue score_val max(0, min(100, float(score_val))) score Score.query.filter_by(student_idstudent.id, course_idcourse_id).first() if score: score.score score_val else: db.session.add(Score(student_idstudent.id, course_idcourse_id, scorescore_val)) imported 1 db.session.commit() return imported, errors这段代码有几个细节值得注意pandas读取Excel后数据可能是float或str统一转回字符串再匹配学号避免1001与1001.0不匹配的问题成绩范围用max/min强制截断到0到100防止脏数据遇到已存在记录就更新不存在才新增实现可重复导入不重复导出的设计更简单构造一个df调用to_excel写入内存的BytesIO对象再通过Flask的send_file返回下载from io import BytesIO from flask import send_file def export_scores(course_id): course Course.query.get(course_id) records Score.query.filter_by(course_idcourse_id).join(Student).all() data [{ 学号: r.student.student_no, 姓名: r.student.name, 班级: r.student.class_name, 成绩: r.score } for r in records] df pd.DataFrame(data) output BytesIO() with pd.ExcelWriter(output, engineopenpyxl) as writer: df.to_excel(writer, indexFalse, sheet_namecourse.course_name) output.seek(0) filename f{course.course_name}成绩.xlsx filename quote(filename) return send_file( output, as_attachmentTrue, mimetypeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet, download_namefilename )注意download_name里的文件名需要URL编码否则浏览器下载时中文文件名会乱码。4. Vue3前端实现与联调4.1 前端工程初始化与依赖安装前端工程我推荐直接用Vite脚手架创建不要手动配置Webpacknpm create vitelatest frontend -- --template vue cd frontend npm install vue-router4 pinia axios element-plus element-plus/icons-vue echarts这里Node.js版本很重要。Vite 5要求Node 18建议用Node 20 LTS。我用过Node 16时代的老项目升级Vite后直接在vite.config.js里就会报错提示版本不足。package.json里我习惯固定版本号而不是用^的范围符号因为Element Plus这种组件库的minor版本更新偶尔会引入样式变化固定版本能减少不确定性dependencies: { axios: ^1.6.2, echarts: ^5.4.3, element-plus: ^2.4.4, pinia: ^2.1.7, vue: ^3.4.5, vue-router: ^4.2.5 }4.2 Axios请求封装与拦截器前端代码里最容易被忽视但最有价值的部分就是Axios封装。统一封装的好处是接口地址统一配置、Token自动携带、错误提示统一处理。// frontend/src/api/request.js import axios from axios import { ElMessage } from element-plus import router from /router 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 { const res response.data if (res.code ! undefined res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response?.status 401) { ElMessage.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(error.response?.data?.message || 网络异常) } return Promise.reject(error) } )拦截器里对401的全局处理是我特别强调的。很多项目只在某一个页面处理登录失效其他页面报错后用户还停留在原地一头雾水。统一在拦截器里处理不管哪个接口返回过期状态都会自动跳回登录页体验提升非常明显。4.3 成绩管理页面核心逻辑成绩管理页面是整个前端最复杂的部分原因是它涉及三个实体的联动选择课程、展示学生列表、编辑成绩。我采用el-table加行内编辑的模式比弹窗编辑更高效。!-- frontend/src/views/ScoreManage.vue 节选 -- script setup import { ref, onMounted } from vue import { getCourseScores, updateScore, deleteScore } from /api/score import { ElMessage, ElMessageBox } from element-plus const courseId ref(null) const courseList ref([]) const tableData ref([]) const loading ref(false) async function loadScores() { if (!courseId.value) return loading.value true try { const res await getCourseScores(courseId.value) tableData.value res.data } finally { loading.value false } } async function handleEdit(row) { if (!row._editing) return try { await updateScore(row.id, { score: row.score }) ElMessage.success(成绩已更新) row._editing false } catch (e) { ElMessage.error(更新失败) } } /script为了让行内编辑体验更好我把编辑和保存两个操作合并成一个流程点击编辑按钮时给行加_editing标记渲染可输入的el-input-number组件保存时校验数据再调接口。4.4 ECharts统计图表的正确打开方式ECharts在Vue3中使用的正确姿势不是引入封装库而是直接用原生ECharts。我在统计页面写了两个核心图表班级平均分对比柱状图和分数段分布饼图。script setup import * as echarts from echarts import { ref, onMounted, onBeforeUnmount } from vue const chartRef ref(null) let chartInstance null onMounted(() { chartInstance echarts.init(chartRef.value) loadStats() }) async function loadStats() { const res await getScoreDistribution() chartInstance.setOption({ tooltip: { trigger: item }, series: [{ type: pie, radius: 60%, data: res.data.segments.map(item ({ name: item.segment, value: item.count })) }] }) } onBeforeUnmount(() { if (chartInstance) { chartInstance.dispose() } }) /script这里有个特别重要的坑ECharts实例在组件卸载时必须手动销毁否则切换路由时内存里会残留旧实例。我在项目里见过图表越切换越卡的Bug基本都是这个原因。除此之外容器的高度必须显式设置。很多刚接触ECharts的人图表不显示查了半天发现div没有固定高度。我在样式中写死height: 400px一劳永逸。4.5 Vite代理配置与跨域前后端分离开发时跨域是第一个坎。解决办法不是在前端开CORS而是通过Vite的代理把请求转发到后端// frontend/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:5000, changeOrigin: true } } } })这样开发时前端请求/api/students会被Vite自动转发到http://localhost:5000/api/students浏览器的地址始终是localhost:5173不存在跨域问题。如果后端也需要支持跨域比如生产环境前端和后端分开放置可以在Flask中使用flask-cors扩展两者本质目标一致但开发环境下用Vite代理更简单、也更安全。5. 本地部署与运行全流程5.1 Python环境准备我推荐使用虚拟环境运行后端避免污染全局Python环境。Python版本建议3.9以上我在项目里用3.10验证过。cd backend python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install -r requirements.txtrequirements.txt内容我整理成了精简版本flask3.0.0 flask-sqlalchemy3.1.2 flask-jwt-extended4.6.0 flask-cors4.0.0 sqlalchemy2.0.25 pandas2.2.0 openpyxl3.1.2这里要注意flask-sqlalchemy 3.x和2.x的API差异较大。如果你参考的资料是旧版的初始化方式会有变化比如db SQLAlchemy(app)和db SQLAlchemy()的区别。我用的是新版的db SQLAlchemy()配合工厂模式。5.2 前端构建与静态资源托管前端开发调试直接用npm run dev。部署到生产环境需要构建cd frontend npm run build构建完的dist目录就是纯静态文件。最省事的部署方式是用Flask直接托管# backend/app/__init__.py 追加 from flask import send_from_directory import os dist_dir os.path.join(os.path.dirname(os.path.dirname(__file__)), ../frontend/dist) app.route(/, defaults{path: }) app.route(/path:path) def serve_frontend(path): if path and os.path.exists(os.path.join(dist_dir, path)): return send_from_directory(dist_dir, path) return send_from_directory(dist_dir, index.html)这个配置的技巧在于前端路由是history模式页面跳转刷新时浏览器会请求/students这类路径后端需要把所有非API路径都返回index.html让Vue Router接管路由。5.3 一键启动脚本为了照顾不太熟悉命令行的用户我在项目根目录写了启动脚本# start.sh cd backend source venv/bin/activate python run.py cd ../frontend npm run devWindows用户可以用.bat版本cd backend call venv\Scripts\activate.bat start /b python run.py cd ../frontend call npm run dev注意后端端口固定为5000前端开发端口5173。两个服务同时启动后打开http://localhost:5173就能访问完整系统。5.4 初始化管理员账户数据库首次启动时自动创建同时需要初始化一个管理员账号。我在__init__.py里写了一个init_admin_user函数def init_admin_user(): if not User.query.filter_by(usernameadmin).first(): admin User(usernameadmin, roleadmin) admin.set_password(admin123) db.session.add(admin) db.session.commit() print(初始化管理员账号: admin / admin123)首次运行后建议立即改密码。这只是课程设计级别的默认行为真实生产环境应该用环境变量注入初始密码。6. 常见问题与排查实录6.1 前端请求一直报404这个问题我遇到太多次了。排查步骤依次确认后端是否真的启动了直接浏览器访问http://localhost:5000/api/students看看返回什么Vite代理是否生效在vite.config.js里确认proxy配置正确请求路径是否正确抓一下浏览器Network面板看实际请求的URL如果是部署后遇到404优先检查Flask托管静态文件的路由是否在API蓝图之后注册。Flask路由按注册顺序匹配如果/api蓝图注册在通配路由之后API请求会被通配路由拦截。6.2 中文乱码问题中文乱码有两种表现第一种是后端返回的JSON中文变成\uXXXX转义序列。这其实是正常现象浏览器端会正确解码。如果实在介意可以在Flask配置里关掉ASCII转义app Flask(__name__) app.json.ensure_ascii False # Flask 2.3 推荐方式第二种是Excel导出后中文乱码通常是download_name没有URL编码或者openpyxl引擎没有正确指定。严格按前面的quote(filename)方案可以解决。6.3 SQLite数据库被锁定表现是操作数据库时报database is locked。SQLite不适合高并发写入场景一旦多个请求同时写数据就会锁库。系统是单人调试场景还好多人同时录入成绩时偶尔复现。解决思路是按优先级开启WAL模式PRAGMA journal_modeWAL;减少单次事务的写入量避免大循环一次性提交把SQLAlchemy连接池的timeout调大由于SQLite的定位是轻量级演示我选择了WAL模式配合合理的错误重试测试下来基本稳定。如果真要支持几十人同时写建议切到MySQL。6.4 Vue3项目启动报Node版本过低npm run dev启动时报Error: requires Node ^18.0.0 || 20.0.0这是Vite 5的硬性要求。Windows下建议用NVM-Windows管理Node版本不要直接去官网装新版覆盖旧版容易留下权限问题。安装Node 20后如果node_modules缓存混乱执行rm -rf node_modules package-lock.json npm install6.5 图表不显示或空白ECharts图表空白90%的原因是容器没有高度。我之前排查过一个案例组件在el-tabs面板里初始宽度为0图表即使初始化也渲染不出内容。解决办法是在el-tabs切换后调用chart.resize()。更稳妥的方案是使用nextTickimport { nextTick } from vue await nextTick() chartInstance echarts.init(chartRef.value)6.6 JWT Token过期后页面无感刷新JWT过期后用户操作会突然弹登录已过期然后跳回登录页体验不够友好。改进做法在响应拦截器里捕获401调用后端加一个刷新Token接口重新签发失败再跳登录页。这个方案我在项目后续迭代中加入了有效减少了用户频繁登录的烦恼。7. 一个加分项建议给系统加一个成绩趋势分析如果上面这些都做完了想再进一步把项目深度提上去我会建议加一个成绩趋势分析模块。实现逻辑不复杂按考试批次为维度统计某位学生或某个班级多门课程的成绩变化趋势用折线图展示。这个模块的技术关键点在于后端需要为Score表增加exam_batch字段标识每次考试轮次# 示例按考试批次统计 results db.session.query( Score.exam_batch, func.avg(Score.score) ).filter( Score.course_id course_id ).group_by( Score.exam_batch ).order_by( Score.exam_batch ).all()前端用折线图展示每次考试的平均分变化如果再叠加一个班级平均分参考线效果会非常直观。经过这个功能的锻炼你对数据模型设计需要为扩展预留字段这句话会有真正深入的理解。我自己在实际使用这套系统的体会是做完基础功能只是第一步把统计分析、批量导入、异常处理这些细节打磨到位才配得上优质系统这个评价。如果你在复现过程中遇到问题优先检查版本兼容性和数据库状态这两个环节占了项目运行失败原因的绝大部分。如果还想往更大规模的方向扩展可以尝试把SQLite切换为MySQL同时加入Redis缓存热点数据整套架构的进阶空间还很大。最后再分享一个小技巧开发调试时善用Flask的调试模式和Python的print输出——在关键接口入口打一条日志能快速定位请求是否到达后端、参数是否正确。我见过太多人一上来就怼着前端代码改结果问题出在后端路由根本没注册上。先确认模块边界再逐层排查效率会高得多。