简介:这是一套面向Python初学者与教学管理者的匿名考试成绩登记与学情分析系统源码,适合作为课程设计、毕业设计或教学工具二次开发参考。项目以Python脚本承担数据处理、事件调度与主控逻辑,配合HTML页面、JavaScript交互与CSS样式构建前端界面,并借助SQLite3数据库实现成绩的持久化存储,同时通过日志配置与匿名化设计兼顾数据安全与隐私保护。资源包共24个文件,涵盖6个HTML页面、5个Python脚本、5个JavaScript文件、2个CSS样式表,以及数据库、配置、字体与说明文档等,压缩包约1.64MB,目录结构清晰,便于按模块阅读与调试。目前已有303人学习下载。读者可从中获取前后端协同的完整实现思路、匿名成绩录入与学情分析的落地方法,以及数据库选型、日志管理与前端组件集成的实践经验,适合用来快速理解小型教育管理系统的整体架构与开发流程。
1. 匿名成绩登记系统:为什么教务场景需要它,以及这套 Python 方案能解决什么
每到期中期末,教务老师最头疼的不是改卷,而是把成绩从各个 Excel 里汇总、核对、再分发给不同班级。传统做法是让课代表用共享表格填分数,结果学号姓名全暴露,谁考了多少一目了然,学生之间互相打听、家长群里截图传播,隐私问题年年出。更麻烦的是,成绩收上来之后还要手动算平均分、及格率、分数段分布,一个班五十人还能忍,一个年级上千条记录,光透视表就能折腾一下午。
这套「基于 Python 的匿名考试成绩登记与学情分析」方案,核心思路就两件事:登记环节用匿名编号替代真实身份,分析环节用 Pandas 自动出统计报表。它适合中小学教务、培训机构班主任、高校助教这类需要频繁处理成绩但又没有专业教务系统权限的角色。整套东西不依赖数据库,一个 Flask 服务加几个 Python 脚本就能跑起来,源码结构清晰,改起来也方便。下面从环境搭建到匿名映射、再到学情分析,把能复现的步骤和参数都拆开讲。
2. 环境准备与项目骨架:从零把 Flask + Pandas 跑起来
2.1 依赖选型:为什么用 Flask 而不是 Django
成绩登记这种场景,功能边界很清楚:一个表单页、一个提交接口、一个分析页。Django 自带 admin、ORM、模板引擎,对这个小需求来说太重了,光 settings.py 就要改半天。Flask 的轻量在这里是优势,路由和视图函数一一对应,新手看一遍就能改。
核心依赖就四个:
| 库 | 用途 | 安装命令 |
|---|---|---|
| Flask | 提供 Web 服务和表单接口 | pip install flask |
| Pandas | 成绩数据清洗与统计 | pip install pandas |
| openpyxl | 读写 Excel 文件 | pip install openpyxl |
| hashlib | 生成匿名编号(标准库) | 无需安装 |
Python 版本建议 3.8 以上,Pandas 在 3.8 之后的版本对 Excel 读取的兼容性更稳。如果你用的是 python 3.8,注意 openpyxl 要装 3.0.9 之后的版本,否则读 xlsx 会报BadZipFile。
# 创建虚拟环境,避免污染全局包 python -m venv venv # Windows 激活 venv\Scripts\activate # macOS / Linux 激活 source venv/bin/activate # 一次性安装依赖 pip install flask pandas openpyxl装完之后用pip list确认一下版本,Flask 2.x 和 3.x 在路由写法上略有差异,下面代码以 Flask 2.3 为准,3.x 也能跑。
2.2 项目目录结构:四个文件撑起整个系统
不要一上来就搞蓝图、搞工厂模式,这个体量用不上。我一般会这样组织:
score_system/ ├── app.py # Flask 主程序,路由和接口 ├── anonymize.py # 匿名编号生成与映射 ├── analysis.py # 学情分析统计逻辑 ├── templates/ │ ├── index.html # 成绩录入页 │ └── report.html # 分析报表页 └── data/ ├── raw_scores.xlsx # 原始成绩(含真实身份) └── anon_map.csv # 匿名映射表(单独存放)关键点是anon_map.csv必须和raw_scores.xlsx分开存。很多翻车案例就是把映射表和分析结果放同一个目录,结果打包发给学生的时候连映射一起发出去了,匿名等于没做。
2.3 最小可运行版本:先把 Flask 跑起来
# app.py from flask import Flask, render_template, request, redirect, url_for import pandas as pd import os app = Flask(__name__) DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') RAW_FILE = os.path.join(DATA_DIR, 'raw_scores.xlsx') @app.route('/') def index(): """渲染成绩录入表单页""" return render_template('index.html') @app.route('/submit', methods=['POST']) def submit(): """接收表单提交,追加写入 Excel""" # 从表单取字段,字段名要和 HTML 里的 name 属性一致 student_id = request.form.get('student_id', '').strip() name = request.form.get('name', '').strip() score = request.form.get('score', '').strip() # 基础校验:学号、姓名、分数都不能为空 if not student_id or not name or not score: return '字段不完整,请返回重填', 400 try: score_val = float(score) except ValueError: return '分数必须是数字', 400 # 如果文件不存在就新建,存在就追加 if os.path.exists(RAW_FILE): df = pd.read_excel(RAW_FILE) else: df = pd.DataFrame(columns=['student_id', 'name', 'score']) new_row = pd.DataFrame([{'student_id': student_id, 'name': name, 'score': score_val}]) df = pd.concat([df, new_row], ignore_index=True) df.to_excel(RAW_FILE, index=False) return redirect(url_for('index')) if __name__ == '__main__': # debug=True 仅用于开发,上线要关掉 app.run(debug=True, host='0.0.0.0', port=5000)这段代码的逻辑很直白:/返回录入页面,/submit接收 POST 请求,把学号、姓名、分数追加到 Excel。参数上注意host='0.0.0.0'是让同局域网的其他机器也能访问,方便课代表在自己电脑上填;port=5000是 Flask 默认端口,如果被占用改成 5001 即可。debug=True会在代码改动后自动重启,但生产环境必须关掉,否则报错页面会暴露源码路径。
对应的templates/index.html最简版本:
<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>成绩录入</title></head> <body> <h2>匿名成绩登记</h2> <form action="/submit" method="post"> <label>学号:<input type="text" name="student_id" required></label><br> <label>姓名:<input type="text" name="name" required></label><br> <label>分数:<input type="number" name="score" step="0.5" required></label><br> <button type="submit">提交</button> </form> </body> </html>step="0.5"允许输入 0.5 的倍数,如果你们学校分数只取整数,改成step="1"。required是浏览器层面的必填校验,但后端仍然要再校验一次,因为用户可以绕过前端直接发请求。
3. 匿名编号生成:把学号姓名从成绩表里剥离出去
3.1 匿名映射的原理:哈希加盐而不是简单替换
有些教程教你直接把学号替换成 1、2、3 这样的序号,问题是序号和学号一一对应,稍微懂点规律的人拿到两份表就能反推。更稳的做法是用哈希函数加盐,把「学号 + 姓名 + 盐值」算出一个固定长度的字符串作为匿名编号。
# anonymize.py import hashlib import pandas as pd import os # 盐值:每个学校或每个学期换一次,不要写死在代码里提交到仓库 SALT = 'your_school_2024_secret' def make_anon_id(student_id: str, name: str) -> str: """根据学号和姓名生成匿名编号,取哈希前 10 位""" raw = f"{student_id}|{name}|{SALT}" return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:10] def build_anon_map(raw_file: str, map_file: str): """读取原始成绩,生成匿名映射表并保存""" df = pd.read_excel(raw_file) # 去重:同一个学生可能多次提交,只保留一条映射 df_unique = df[['student_id', 'name']].drop_duplicates() df_unique['anon_id'] = df_unique.apply( lambda row: make_anon_id(str(row['student_id']), str(row['name'])), axis=1 ) df_unique.to_csv(map_file, index=False, encoding='utf-8-sig') return df_uniqueSALT这个参数是安全性的关键。如果盐值泄露,别人拿到匿名编号和学号列表就能暴力比对。实际部署时把盐值放到环境变量里,代码里用os.environ.get('SCORE_SALT')读取,不要硬编码。hexdigest()[:10]取前 10 位,碰撞概率在几千人规模下可以忽略,如果你们学校上万人,取 12 位更保险。
3.2 把匿名编号回填到成绩表
生成映射之后,下一步是把匿名编号合并回成绩数据,然后丢掉学号和姓名列。
def anonymize_scores(raw_file: str, map_file: str, output_file: str): """生成不含真实身份的匿名成绩表""" df = pd.read_excel(raw_file) mapping = pd.read_csv(map_file, dtype={'student_id': str}) # 统一 student_id 类型,避免 int 和 str 合并失败 df['student_id'] = df['student_id'].astype(str) mapping['student_id'] = mapping['student_id'].astype(str) merged = df.merge(mapping[['student_id', 'anon_id']], on='student_id', how='left') # 只保留匿名编号和分数,丢掉姓名和学号 anon_df = merged[['anon_id', 'score']] anon_df.to_excel(output_file, index=False) return anon_df这里有个血泪经验:student_id在 Excel 里经常被存成数字,Pandas 读进来是 int64,而映射表里是字符串,直接 merge 会报You are trying to merge on int64 and object columns。解决办法就是两边都astype(str),这一步不做,后面分析全是 NaN。
3.3 匿名编号的验证:确保没有遗漏和重复
生成完匿名表之后,跑一段校验代码,确认三件事:匿名编号数量等于学生数量、没有空值、没有重复。
def validate_anon(anon_file: str): """校验匿名成绩表的完整性""" df = pd.read_excel(anon_file) total = len(df) unique_ids = df['anon_id'].nunique() null_count = df['anon_id'].isna().sum() print(f"总记录数:{total}") print(f"唯一匿名编号数:{unique_ids}") print(f"空匿名编号数:{null_count}") if null_count > 0: print("警告:存在未匹配到匿名编号的记录,检查学号格式是否一致") if unique_ids < total: print("提示:存在重复提交,建议按学号去重后再分析")如果null_count大于 0,八成是学号格式问题,比如原始表里学号带空格或者有前导零被 Excel 吃掉了。解决办法是在读取时加dtype={'student_id': str},并且在录入环节就用文本格式存学号。
4. 学情分析核心逻辑:平均分、及格率、分数段一次算清
4.1 用 Pandas 做描述性统计:五个指标覆盖大部分需求
学情分析不需要多复杂的模型,教务最关心的就是平均分、最高分、最低分、及格率、优秀率这五个。Pandas 的describe()能给一部分,但及格率和优秀率要自己算。
# analysis.py import pandas as pd def basic_stats(anon_file: str, pass_line: float = 60, excellent_line: float = 85): """计算基础统计指标""" df = pd.read_excel(anon_file) scores = df['score'].dropna() stats = { '总人数': len(scores), '平均分': round(scores.mean(), 2), '最高分': scores.max(), '最低分': scores.min(), '标准差': round(scores.std(), 2), '及格率': round((scores >= pass_line).sum() / len(scores) * 100, 2), '优秀率': round((scores >= excellent_line).sum() / len(scores) * 100, 2), } return statspass_line和excellent_line做成参数,是因为不同科目、不同学段的及格线和优秀线不一样。小学可能 60 分及格、90 分优秀,高中可能 60 分及格、85 分优秀。做成参数之后,换一个科目只需要改调用时的实参,不用动函数体。
4.2 分数段分布:用 cut 做等距分组
分数段分布是学情分析里最能看出问题的部分。哪个分数段扎堆,说明教学难度可能偏了。
def score_distribution(anon_file: str, bins=None): """按分数段统计人数和占比""" if bins is None: # 默认分段:0-59, 60-69, 70-79, 80-89, 90-100 bins = [0, 60, 70, 80, 90, 101] labels = ['不及格', '及格', '中等', '良好', '优秀'] df = pd.read_excel(anon_file) df['段位'] = pd.cut(df['score'], bins=bins, labels=labels, right=False) dist = df['段位'].value_counts().reindex(labels, fill_value=0) dist_pct = (dist / dist.sum() * 100).round(2) result = pd.DataFrame({'人数': dist, '占比(%)': dist_pct}) return resultright=False表示左闭右开,也就是 60 分算进「及格」而不是「不及格」。这个参数很关键,默认right=True的话 60 分会被分到 0-59 那一段,教务看到会来找你。bins的最后一个值写 101 而不是 100,是因为pd.cut的右边界是开区间,写 100 的话满分 100 的学生会被排除在外。
4.3 把分析结果渲染成报表页
统计逻辑有了,接下来接到 Flask 路由里,渲染成 HTML 表格。
# app.py 追加部分 from analysis import basic_stats, score_distribution @app.route('/report') def report(): """渲染学情分析报表""" anon_file = os.path.join(DATA_DIR, 'anon_scores.xlsx') if not os.path.exists(anon_file): return '尚未生成匿名成绩表,请先运行 anonymize.py', 404 stats = basic_stats(anon_file) dist = score_distribution(anon_file) # 把 DataFrame 转成 HTML 表格,方便模板直接渲染 dist_html = dist.to_html(classes='table', border=0) return render_template('report.html', stats=stats, dist_html=dist_html)to_html生成的表格自带border属性,传 0 去掉边框,样式交给 CSS 控制。classes='table'方便你在 CSS 里统一调样式。如果数据量大,to_html会生成很长的字符串,但几千条记录完全没问题。
对应的report.html:
<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>学情分析报表</title></head> <body> <h2>学情分析报表</h2> <table> {% for key, value in stats.items() %} <tr><td>{{ key }}</td><td>{{ value }}</td></tr> {% endfor %} </table> <h3>分数段分布</h3> {{ dist_html | safe }} </body> </html>| safe是告诉 Jinja2 这段 HTML 是安全的,不要转义。不加的话页面上会显示一堆<table>标签文本而不是表格。
5. 避坑与排查:匿名成绩系统最容易翻车的五个地方
5.1 现象:分析结果里出现大量 NaN,平均分算出来是 nan
原因:匿名映射合并时student_id类型不一致,Excel 读进来是 int,CSV 读进来是 str,merge 之后匹配不上,anon_id全是 NaN,再往后score也跟着丢。
解决:所有涉及student_id的读取都加dtype={'student_id': str},merge 之前再astype(str)一次。养成习惯,学号永远当字符串处理,不要让它变成数字。
5.2 现象:60 分的学生被算进不及格,教务说统计错了
原因:pd.cut默认right=True,区间是左开右闭,60 分落在 (0, 60] 这个区间里,被归到不及格段。
解决:显式传right=False,让区间变成左闭右开,60 分归入及格段。这个参数不写,默认行为就是错的,而且错得很隐蔽,不仔细核对根本发现不了。
5.3 现象:多个课代表同时提交,后提交的覆盖了先提交的
原因:pd.read_excel读进来,追加一行,再to_excel写回去。两个请求同时执行时,A 读了文件,B 也读了同一个文件,A 写完 B 再写,A 的数据就丢了。
解决:小规模场景加一个文件锁就行。用threading.Lock()包住读写操作,或者更简单粗暴一点,每个提交生成一个独立的 CSV 文件,分析时再合并。Flask 默认是单进程多线程,加锁能解决大部分并发问题。
5.4 现象:Excel 里的学号前导零丢了,001 变成 1
原因:Excel 默认把纯数字字符串当数字处理,前导零被吃掉。Pandas 读的时候如果没指定 dtype,也会跟着丢。
解决:录入环节就用文本格式存学号,在 Excel 里先把学号列设成文本格式再填。代码层面读取时加dtype={'student_id': str},双保险。
5.5 现象:部署到服务器后,app.run(debug=True)报错页面暴露了文件路径
原因:开发模式下的 Werkzeug 调试器会在浏览器里显示完整的调用栈和源码片段,包括服务器上的绝对路径。
解决:上线前把debug改成False,用gunicorn或waitress这类 WSGI 服务器来跑。如果只是校内局域网用,至少也要把debug关掉,host改成具体的内网 IP 而不是0.0.0.0。
6. 进阶技巧:用箱线图快速定位异常分数和班级差异
基础统计跑通之后,下一步值得做的是可视化。不用搞太复杂的图表,一个箱线图就能看出很多问题:哪个班的分数分布更集中、有没有异常低分、中位数差距大不大。
import matplotlib matplotlib.use('Agg') # 无图形界面服务器必须加这行 import matplotlib.pyplot as plt import pandas as pd # 中文字体设置,Windows 用 SimHei,macOS 用 Arial Unicode MS plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False def plot_box(anon_file: str, group_file: str, output_png: str): """按班级分组画箱线图""" scores = pd.read_excel(anon_file) groups = pd.read_csv(group_file, dtype={'anon_id': str}) merged = scores.merge(groups, on='anon_id', how='left') fig, ax = plt.subplots(figsize=(10, 6)) merged.boxplot(column='score', by='class_name', ax=ax) ax.set_title('各班成绩分布箱线图') ax.set_ylabel('分数') plt.suptitle('') # 去掉 pandas 自动加的副标题 plt.tight_layout() plt.savefig(output_png, dpi=150) plt.close()matplotlib.use('Agg')这行是给没有显示器的服务器用的,不加会报no display name错误。plt.suptitle('')是去掉 Pandas 自动加的那行「Boxplot grouped by class_name」,不然图上方会多一行字。dpi=150保证导出的图片在 Word 里插入后不模糊。
箱线图看完之后,如果发现某个班的中位数明显偏低,可以进一步用groupby算各班均分:
def class_compare(anon_file: str, group_file: str): """按班级对比平均分和及格率""" scores = pd.read_excel(anon_file) groups = pd.read_csv(group_file, dtype={'anon_id': str}) merged = scores.merge(groups, on='anon_id', how='left') result = merged.groupby('class_name')['score'].agg( 人数='count', 平均分='mean', 及格率=lambda x: (x >= 60).sum() / len(x) * 100 ).round(2) return result.sort_values('平均分', ascending=False)agg里用 lambda 算及格率,比先分组再循环简洁得多。sort_values按平均分降序排,一眼就能看出哪个班考得好。这套东西跑下来,从录入到出报表,一个年级的数据半小时内能搞定,比手动透视表快得多,而且匿名映射单独存放,发给学生看的只有匿名编号和分数,隐私问题也解决了。
我自己踩过最深的坑是盐值硬编码在代码里然后传了 Git 仓库,后来改成环境变量才踏实。如果你打算把这套东西长期用下去,盐值管理、映射表权限、定期清理原始数据这三件事,比代码本身更值得花时间。希望帮到你。
本文还有配套的精品资源,点击获取