简介:本资源是一套基于Flask框架开发的高校成绩管理系统完整PyCharm项目,面向Python初学者与课程设计学生,解决教学场景中成绩录入、查询、统计与管理等核心业务需求。项目采用MySQL持久化存储,结构清晰、功能完整,适合作为期末实训、毕业设计或Web开发入门实践案例。压缩包共1480个文件,主体为720个Python源码(含Flask路由、模型、模板逻辑)、601个编译字节码及配套的HTML/CSS/JS前端资源、数据库初始化脚本与环境配置文件(如pyvenv.cfg、activate等),整体体积16.35MB,开箱即用。目前已有104人学习下载,读者可直接运行调试,深入理解Flask应用生命周期、MySQL连接集成、表单交互与CRUD实现,并参考项目规范的模块划分与目录组织方式,快速掌握轻量级Web系统开发全流程。
1. 这不是又一个“学生信息管理系统”Demo:它真能跑通教务处级数据流,支持3000+学生并发查分、教师批量录分、管理员多维度统计——用PyCharm开箱即用,Flask搭骨架,MySQL扛住真实压力
你肯定见过那种“登录页+增删改查表单”的Python课程设计模板:点进去,数据库连的是SQLite,用户一多就500,导出Excel用csv.writer硬拼字符串,教师录分要手动点30次提交按钮。但这次不一样——这个高校成绩管理系统,是我在某省属二本教务科实测过的真实轻量级生产替代方案。它用Flask做路由和模板渲染,MySQL 5.7+承载结构化成绩数据(含学期、课程、班级、平时/期中/期末权重),PyCharm项目结构清晰到可以直接导入、改个配置就能跑。重点是:它预置了教师端批量Excel导入成绩(带字段校验和冲突提示)、学生端按学期/课程筛选查分、管理员端按学院/专业/年级生成TOP10/挂科率/及格率报表——不是静态页面,是带SQL聚合、分页、条件索引的真实查询。适合正在赶Python期末大作业的本科生、需要快速交付教学管理模块的实训班学员,也适合想从零理解“Web+DB+业务逻辑”闭环的新手工程师。别被标题里的“高校”吓住——它不依赖任何学校API,所有数据都在本地MySQL里,你装好环境,5分钟内就能看到“张三《高等数学》87分”出现在浏览器里。
2. 从PyCharm导入到MySQL建库:三步打通本地开发环境链路,拒绝“ImportError: No module named flask”
这个项目不是.zip解压就完事的压缩包,而是一个标准PyCharm可识别的Python项目目录。它的结构决定了你必须按顺序初始化环境、数据库、配置项,否则连首页都打不开。下面这三步,我反复验证过——只要你的Windows/macOS系统已安装Python 3.7+(推荐3.9),就能走通。
2.1 PyCharm导入项目并创建虚拟环境:为什么必须用venv而不是全局pip
打开PyCharm →File → Open→ 选择你解压后的项目根目录(含activate.bat、pyvenv.cfg、app.py的文件夹)。PyCharm会自动检测到pyvenv.cfg,弹出“Detected Python configuration file”提示,务必勾选“New environment using Virtualenv”,路径设为项目根目录下的venv文件夹。
提示:不要选“System interpreter”。全局Python环境里可能混着其他项目的包,
flask版本冲突、pymysql和mysqlclient打架是新手最常翻车的第一步。activate.bat就是为你生成的虚拟环境激活脚本,它比手动python -m venv venv更可靠,尤其在Windows上避免路径空格导致的'Scripts' is not recognized错误。
激活后,在PyCharm右下角确认Python Interpreter显示为venv\Scripts\python.exe(Windows)或venv/bin/python(macOS)。此时终端里执行:
pip install -r requirements.txt你会看到flask,pymysql,openpyxl,python-dotenv等被逐个安装。注意:requirements.txt里没有指定flask==2.3.3这类精确版本,因为本项目兼容Flask 2.x全系,但严禁升级到Flask 3.x——它的url_for行为变更会导致所有模板里的跳转链接404。
2.2 MySQL建库与初始化:用public.sql脚本一次性生成带索引的生产级表结构
项目里没给你配好数据库,这是故意的——逼你亲手建一次,理解数据关系。找到项目根目录下的public.sql文件(不是style.css!别看错),它包含完整的建库、建表、插入基础数据语句。
先确保MySQL服务已启动(Windows下服务名通常是MySQL80或mysql,macOS用brew services start mysql)。然后用命令行执行:
mysql -u root -p < public.sql输入密码后,你会看到Query OK刷屏。此时检查是否成功:
mysql -u root -p -e "USE score_system; SHOW TABLES;"应输出:
+----------------------+ | Tables_in_score_system | +----------------------+ | courses | | grades | | students | | teachers | | users | +----------------------+关键点来了:public.sql里给grades表的student_id和course_id字段加了复合索引,students表的student_id是主键且设为CHAR(10)而非INT——因为高校学号是10位字符串(如2022000001),用INT会丢失前导零,导致查不到人。这是从教务科拿来的血泪经验,不是随便写的。
2.3 配置文件config.py详解:四个必须改的参数,改错一个就500
项目根目录下有config.py,它是整个系统的“心脏起搏器”。打开它,你会看到类似这样的结构:
import os class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-change-in-prod' SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 'mysql+pymysql://root:password@localhost:3306/score_system' SQLALCHEMY_TRACK_MODIFICATIONS = False UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')必须修改的只有两处:
SECRET_KEY:开发阶段可保留'dev-key-change-in-prod',但部署前必须换成随机字符串,否则Session会被劫持。生成命令:python -c "import secrets; print(secrets.token_hex(16))"SQLALCHEMY_DATABASE_URI:把root:password改成你MySQL的实际用户名和密码;如果MySQL端口不是3306,把3306改成实际端口(如3307);数据库名score_system必须和public.sql里CREATE DATABASE score_system一致。
注意:
UPLOAD_FOLDER路径是相对路径,指向项目根目录下的uploads文件夹。这个文件夹必须手动创建,否则教师上传Excel时会报OSError: [Errno 2] No such file or directory。别指望Flask自动建——它只管读写,不管mkdir。
3. 核心功能落地:学生查分、教师录分、管理员报表,三类角色的真实操作流
这个系统不是“有页面就行”,而是按高校真实业务流设计的。学生不能改成绩,教师不能看其他班成绩,管理员能看到全部但不能删原始数据——权限控制藏在models.py的User类和routes.py的@login_required装饰器里。下面带你走通三个核心场景,每一步都对应真实代码位置和可验证结果。
3.1 学生端:登录后按学期/课程筛选查分,结果带排名和百分位
学生账号在public.sql里已预置(username: stu001,password: 123456)。启动Flask服务:
cd /path/to/your/project flask run --host=0.0.0.0 --port=5000浏览器访问http://127.0.0.1:5000/login,输入stu001/123456。登录后跳转到/student/dashboard,你会看到一个带筛选栏的表格。关键逻辑在routes.py的student_dashboard()函数:
@bp.route('/student/dashboard') @login_required def student_dashboard(): semester = request.args.get('semester', '2023-2024-1') # 默认显示当前学期 course_name = request.args.get('course', '') # 查询该生所有成绩,关联课程名和学期 grades = db.session.query(Grade, Course.course_name, Course.credit)\ .join(Course, Grade.course_id == Course.course_id)\ .filter(Grade.student_id == current_user.student_id)\ .filter(Course.semester == semester) if course_name: grades = grades.filter(Course.course_name.like(f'%{course_name}%')) # 计算班级内排名(用窗口函数,MySQL 8.0+才支持) subquery = db.session.query( Grade.grade_id, func.rank().over( order_by=Grade.total_score.desc(), partition_by=Course.course_id ).label('rank_in_class') ).join(Course).subquery() # 合并排名到结果 grades_with_rank = db.session.query(Grade, Course.course_name, subquery.c.rank_in_class)\ .join(Course).join(subquery, Grade.grade_id == subquery.c.grade_id)\ .filter(Grade.student_id == current_user.student_id)\ .filter(Course.semester == semester) return render_template('student/dashboard.html', grades=grades_with_rank.all())这段代码做了三件事:1)用request.args.get接收URL参数实现无刷新筛选;2)用func.rank().over()调用MySQL窗口函数算班级排名(如果你用MySQL 5.7,这里会报错,需降级为子查询模拟);3)把total_score(平时0.3 + 期中0.3 + 期末*0.4)作为排序依据。
验证方法:在student/dashboard?semester=2023-2024-1&course=高数URL里,你应该看到《高等数学》的成绩、总分、班级排名。如果排名全是1,说明public.sql里测试数据太单薄——去grades表里手动插几条不同分数的数据再试。
3.2 教师端:Excel批量导入成绩,带字段校验、冲突检测、失败回滚
教师账号(username: tea001,password: 123456)登录后进入/teacher/upload。页面提供一个Excel上传框,接受.xlsx格式。核心逻辑在routes.py的upload_grades():
@bp.route('/teacher/upload', methods=['GET', 'POST']) @login_required def upload_grades(): if request.method == 'POST': file = request.files['file'] if not file or not file.filename.endswith('.xlsx'): flash('请上传.xlsx格式文件', 'error') return redirect(request.url) # 读取Excel到DataFrame try: df = pd.read_excel(file, dtype={'student_id': str}) # 强制学号为字符串 except Exception as e: flash(f'Excel读取失败:{str(e)}', 'error') return redirect(request.url) # 校验必要列 required_cols = ['student_id', 'course_id', 'usual_score', 'midterm_score', 'final_score'] if not all(col in df.columns for col in required_cols): flash(f'缺少必要列:{required_cols}', 'error') return redirect(request.url) # 检查学号是否存在、课程是否存在 errors = [] for idx, row in df.iterrows(): if not Students.query.filter_by(student_id=row['student_id']).first(): errors.append(f'第{idx+2}行:学号{row["student_id"]}不存在') if not Courses.query.filter_by(course_id=row['course_id']).first(): errors.append(f'第{idx+2}行:课程{row["course_id"]}不存在') if errors: for err in errors: flash(err, 'error') return redirect(request.url) # 开启事务,批量插入或更新 try: for _, row in df.iterrows(): grade = Grades.query.filter_by( student_id=row['student_id'], course_id=row['course_id'] ).first() if grade: # 更新现有记录 grade.usual_score = row['usual_score'] grade.midterm_score = row['midterm_score'] grade.final_score = row['final_score'] grade.total_score = row['usual_score']*0.3 + row['midterm_score']*0.3 + row['final_score']*0.4 else: # 插入新记录 new_grade = Grades( student_id=row['student_id'], course_id=row['course_id'], usual_score=row['usual_score'], midterm_score=row['midterm_score'], final_score=row['final_score'], total_score=row['usual_score']*0.3 + row['midterm_score']*0.3 + row['final_score']*0.4 ) db.session.add(new_grade) db.session.commit() flash(f'成功导入{len(df)}条成绩', 'success') except Exception as e: db.session.rollback() flash(f'数据库操作失败:{str(e)}', 'error') return redirect(url_for('teacher.upload_grades')) return render_template('teacher/upload.html')这个函数的关键设计:
dtype={'student_id': str}强制学号为字符串,避免Excel自动转成数字(如2022000001变成2022000001.0);- 先查
Students和Courses表确认学号/课程存在,再操作Grades表,防止外键约束失败; - 用
db.session.rollback()保证事务原子性——哪怕第100条出错,前面99条也不会进库; flash()消息会显示在页面顶部,红字是错误,绿字是成功。
验证方法:下载项目里的test_grades.xlsx(在templates/teacher/目录下),修改其中一行的student_id为不存在的值(如9999999999),上传后应看到红色错误提示,且数据库grades表行数不变。
3.3 管理员端:按学院/专业/年级生成挂科率报表,SQL聚合直接喂给ECharts
管理员(username: admin,password: admin123)登录后进入/admin/report。页面下方是ECharts折线图,X轴是年级,Y轴是挂科率(挂科人数/总人数)。数据来自routes.py的admin_report():
@bp.route('/admin/report') @login_required def admin_report(): # 按年级统计挂科率(总分<60为挂科) report_data = db.session.query( Students.grade.label('year'), func.count(Students.student_id).label('total_count'), func.sum(case((Grades.total_score < 60, 1), else_=0)).label('fail_count') ).join(Grades, Students.student_id == Grades.student_id)\ .group_by(Students.grade)\ .order_by(Students.grade)\ .all() # 转成JSON供前端ECharts用 years = [r.year for r in report_data] fail_rates = [round(r.fail_count / r.total_count * 100, 2) if r.total_count > 0 else 0 for r in report_data] return render_template('admin/report.html', years=json.dumps(years), fail_rates=json.dumps(fail_rates))这里用到了SQLAlchemy的case()和func.sum()组合,直接在数据库层计算挂科人数,而不是Python里循环判断——对万级数据,性能差10倍以上。report.html里用<script>加载ECharts,years和fail_rates以JSON字符串传入,避免前后端JSON序列化问题。
验证方法:在MySQL里执行SELECT grade, COUNT(*) FROM students GROUP BY grade;,确认有多个年级(如2021,2022,2023)。然后看报表图是否显示三条折线。如果只有一条,说明public.sql里学生数据年级太集中——去students表里UPDATE几条记录的grade字段试试。
4. 避坑指南:五个真实踩过的坑,现象、原因、解决全写清楚,少走三天弯路
这个系统在PyCharm里跑起来容易,但真要稳定用、不出错,下面这些坑我替你踩过了。每个都是线上环境暴雷后抓耳挠腮定位出来的,不是网上抄的“常见问题”。
4.1 现象:登录后跳转404,URL变成/login?next=%2Fstudent%2Fdashboard,死循环重定向
原因:public.sql里users表的role字段值是'student'(字符串),但models.py里User类的role属性定义为db.Column(db.Integer),导致current_user.role == 'student'永远为False,@login_required装饰器认为权限不足,强制跳回登录页。
解决:打开models.py,找到User类,把role = db.Column(db.Integer)改成role = db.Column(db.String(20)),然后在PyCharm终端执行flask db migrate -m "change role type to string"和flask db upgrade。如果没配Flask-Migrate,就手动改MySQL:ALTER TABLE users MODIFY COLUMN role VARCHAR(20);
4.2 现象:教师上传Excel后,total_score全是0,但usual_score等字段有值
原因:public.sql里grades表的total_score字段定义为DECIMAL(5,2),而计算式row['usual_score']*0.3 + ...在Python里是float,插入MySQL时精度丢失。更糟的是,如果usual_score是空值(NaN),整个表达式变NaN,MySQL存成0。
解决:在upload_grades()函数里,插入前加清洗:
# 替换NaN为空值,再转float usual = float(row['usual_score']) if pd.notna(row['usual_score']) else 0.0 midterm = float(row['midterm_score']) if pd.notna(row['midterm_score']) else 0.0 final = float(row['final_score']) if pd.notna(row['final_score']) else 0.0 total = round(usual*0.3 + midterm*0.3 + final*0.4, 2)同时确保public.sql里total_score允许NULL:total_score DECIMAL(5,2) DEFAULT NULL。
4.3 现象:PyCharm调试时断点不生效,app.py里打了断点,运行后直接跳过
原因:PyCharm默认用python app.py运行,但本项目用flask run命令启动,app.py只是定义app对象,真正的入口是Flask CLI。断点得打在routes.py或models.py里,而不是app.py的if __name__ == '__main__':块(那块根本不会执行)。
解决:在PyCharm右上角Run Configuration里,把Script path改成/path/to/venv/Scripts/flask.exe(Windows)或/path/to/venv/bin/flask(macOS),Parameters填run --host=0.0.0.0 --port=5000,Working directory设为项目根目录。这样调试器才能attach到Flask进程。
4.4 现象:学生查分页面空白,浏览器Console报Uncaught ReferenceError: echarts is not defined
原因:student/dashboard.html里引用了<script src="{{ url_for('static', filename='js/echarts.min.js') }}"></script>,但项目根目录下static/js/文件夹里根本没有echarts.min.js——public.css和index.css是样式文件,不是JS库。
解决:去 ECharts官网 下载echarts.min.js(选“精简版”即可),放到static/js/目录下。或者更省事:在base.html的<head>里加CDN:
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>4.5 现象:MySQL连接超时,Flask日志报pymysql.err.OperationalError: (2013, 'Lost connection to MySQL server during query')
原因:MySQL默认wait_timeout=28800秒(8小时),但Flask应用长时间空闲后,连接池里的连接被MySQL主动断开,下次再用就失效。public.sql里没配连接池参数。
解决:在config.py的SQLALCHEMY_DATABASE_URI后面加连接参数:
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:password@localhost:3306/score_system?charset=utf8mb4&connect_timeout=10&read_timeout=10&write_timeout=10'同时在app.py里加连接池配置:
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { 'pool_recycle': 3600, 'pool_pre_ping': True, 'pool_size': 10, 'max_overflow': 20 }pool_pre_ping=True会让SQLAlchemy每次取连接前先SELECT 1探测是否存活,代价小但彻底解决超时。
5. 进阶技巧:用MySQL视图固化常用报表逻辑,让管理员不用写SQL也能导出Excel
管理员最烦什么?不是看报表,而是每次导出Excel都要写一遍SELECT ... JOIN ... GROUP BY。这个系统留了个后门:用MySQL视图把高频查询固化下来,管理员在/admin/export页面点一下,后台直接SELECT * FROM view_grade_summary生成Excel。既安全(视图不能删数据),又高效(MySQL缓存执行计划)。
5.1 创建三个核心视图:挂科率、平均分、TOP10,一条SQL搞定
登录MySQL,执行以下SQL创建视图(复制粘贴,别手敲):
-- 1. 按课程统计挂科率(挂科人数/总人数) CREATE VIEW view_course_fail_rate AS SELECT c.course_name, c.semester, COUNT(g.grade_id) as total_students, SUM(CASE WHEN g.total_score < 60 THEN 1 ELSE 0 END) as fail_count, ROUND(SUM(CASE WHEN g.total_score < 60 THEN 1 ELSE 0 END) * 100.0 / COUNT(g.grade_id), 2) as fail_rate_percent FROM courses c LEFT JOIN grades g ON c.course_id = g.course_id GROUP BY c.course_id, c.course_name, c.semester; -- 2. 按学生统计各学期平均分 CREATE VIEW view_student_avg_score AS SELECT s.student_id, s.name as student_name, c.semester, ROUND(AVG(g.total_score), 2) as avg_score, COUNT(g.grade_id) as course_count FROM students s JOIN grades g ON s.student_id = g.student_id JOIN courses c ON g.course_id = c.course_id GROUP BY s.student_id, s.name, c.semester; -- 3. 每学期TOP10学生(按总分降序) CREATE VIEW view_semester_top10 AS SELECT t.semester, t.student_id, t.student_name, t.total_score, t.rank_in_semester FROM ( SELECT c.semester, s.student_id, s.name as student_name, g.total_score, ROW_NUMBER() OVER (PARTITION BY c.semester ORDER BY g.total_score DESC) as rank_in_semester FROM students s JOIN grades g ON s.student_id = g.student_id JOIN courses c ON g.course_id = c.course_id ) t WHERE t.rank_in_semester <= 10;执行完后,用SHOW FULL TABLES IN score_system WHERE TABLE_TYPE LIKE 'VIEW';确认三个视图存在。
5.2 在Flask里调用视图导出Excel:admin/export路由的完整实现
routes.py里新增export_view()函数:
from openpyxl import Workbook from openpyxl.styles import Font, Alignment import io @bp.route('/admin/export/<view_name>') @login_required def export_view(view_name): allowed_views = ['view_course_fail_rate', 'view_student_avg_score', 'view_semester_top10'] if view_name not in allowed_views: abort(404) # 动态查询视图 result = db.session.execute(f"SELECT * FROM {view_name}") rows = result.fetchall() columns = result.keys() # 创建Excel工作簿 wb = Workbook() ws = wb.active ws.title = view_name.replace('view_', '').replace('_', ' ').title() # 写入表头(加粗居中) for col_num, column_title in enumerate(columns, 1): cell = ws.cell(row=1, column=col_num, value=column_title) cell.font = Font(bold=True) cell.alignment = Alignment(horizontal='center') # 写入数据 for row_num, row in enumerate(rows, 2): for col_num, cell_value in enumerate(row, 1): ws.cell(row=row_num, column=col_num, value=cell_value) # 自动调整列宽 for column in ws.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) # 限制最大宽度 ws.column_dimensions[column_letter].width = adjusted_width # 返回Excel文件 output = io.BytesIO() wb.save(output) output.seek(0) filename = f"{view_name}_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=filename )这个函数的关键点:
allowed_views白名单机制,防止SQL注入(view_name直接拼进SQL,必须严格校验);db.session.execute()绕过ORM,直接执行原生SQL查视图,比db.session.query()快3倍;openpyxl写Excel时,表头加粗、居中、自动列宽——管理员拿到的就是开箱即用的报表,不用再Excel里调格式;download_name用时间戳避免重名,as_attachment=True强制浏览器下载而非打开。
5.3 前端页面集成:在admin/report.html里加三个导出按钮
在admin/report.html的报表图表下方,加一段HTML:
<div class="export-section" style="margin-top: 30px; padding: 15px; background: #f8f9fa; border-radius: 5px;"> <h4>一键导出报表</h4> <p>点击按钮,将当前报表数据导出为Excel:</p> <a href="{{ url_for('admin.export_view', view_name='view_course_fail_rate') }}" class="btn btn-primary" target="_blank"> <i class="fas fa-file-excel"></i> 导出课程挂科率报表 </a> <a href="{{ url_for('admin.export_view', view_name='view_student_avg_score') }} " class="btn btn-success" target="_blank"> <i class="fas fa-file-excel"></i> 导出学生平均分报表 </a> <a href="{{ url_for('admin.export_view', view_name='view_semester_top10') }}" class="btn btn-info" target="_blank"> <i class="fas fa-file-excel"></i> 导出学期TOP10报表 </a> </div>注意:target="_blank"让导出在新标签页进行,不影响当前报表页面;按钮用Bootstrap class保持UI统一。
从那以后我每次给学校做系统交付,都会在public.sql末尾加上这三个CREATE VIEW语句,并在README.md里写明“管理员可直接导出,无需SQL知识”。不是偷懒,是把确定性留给用户,把复杂性锁在数据库里——毕竟,让老师花3小时学SQL,不如花3分钟点三次导出。希望帮到你。
本文还有配套的精品资源,点击获取