Python学生成绩管理系统:JSON存储+Tkinter界面+matplotlib图表实战
2026/9/7 5:46:09 网站建设 项目流程

简介:针对学生成绩管理的常见需求,这套基于Python开发的GUI可视化系统提供了从数据录入、查询修改到统计导出的一整套功能,覆盖多科目成绩管理、平均分/最高分/最低分计算等实用模块,适合刚开始接触桌面应用开发的编程学习者,也可作为高校课程设计的参考项目。压缩包共19个文件,包括6个UI界面设计文件、5个XML界面布局配置、5个文本说明文档以及核心Python脚本,整体仅14KB,轻量且结构完整,便于快速定位源码与界面文件对应关系。已有超过1.7万人学习下载,说明其在同类资源中具备较高参考价值。借助UI文件与脚本的配合,读者可以直观学习Tkinter/PyQt的窗口、表格、按钮等控件设计,理解SQLite数据库的读写操作,并掌握界面与业务逻辑分离的常见实现方式,为后续独立开发小型管理系统打下扎实基础。 学生成绩管理系统听起来是个被写烂了的入门项目,但真的想做到数据不丢、操作顺手、还能把成绩分布画成图表,远没有想的那么简单。期末收成绩时,几十个学生的各科分数散落在不同格式的Excel表里,手动合并、算平均分、排名次、看及格率,一晚上就耗进去了。我前后用Python写了三版,最后这版把JSON数据存储、Tkinter界面、matplotlib图表整合在一个桌面窗口里,鼠标点几下就能完成整场考试的成绩汇总,也顺势把GUI可视化做成了自己一直在用的工具。这篇就把完整思路和核心代码分享出来,适合刚学完Python基础、想做个像样GUI项目的朋友参考。

1. 做这个系统前,先把成绩管理的真正痛点聊透

1.1 三个最耗时间的场景,决定功能边界

我动手写代码之前,先没急着写界面,而是把“成绩管理”这件事拆成了三个高频动作。

第一个是收集合并。不同老师交上来的Excel格式千奇百怪,有人按学号排,有人按姓名排,科目列的顺序也不一样,最后都得人为统一成一张总表,光做数据清洗就累得够呛。第二个是统计排名。算平均分、最高分、最低分、及格率,再按平均分排出名次,Excel里复制公式稍不注意就串行,一个班50个人6门课,经常查半天错在哪。第三个是结果展示。年级组或者家长会真正想看的不是一排数字,而是“这次整体考得怎么样”,成绩分布直方图、及格率饼图,两张图比一屏幕数字直观得多。

把这三个痛点想清楚,功能边界就自然出来了:成绩录入与修改要方便,统计要实时刷新,查询排序要灵活,还要有可视化图表。至于登录页面、权限管理、云同步这些花活,在这个单机场景里都是伪需求,我不会优先做进去,做了反而拖慢开发节奏。

1.2 为什么GUI选了Tkinter而不是PyQt

选型当时纠结了一下,最后综合上手成本、打包体积和维护难度,还是用Tkinter。几个主流方案对比下来:

方案上手成本界面效果打包体积适合场景
Tkinter(标准库自带)低,Python装完就有中规中矩,用ttk主题后尚可小,10MB级别本地小工具、课设、自用
PyQt5 / PySide6中高,需额外安装现代、控件丰富大,50MB以上产品级桌面软件
Web前端 + Flask高,需要起服务取决于前端功底中等多人访问、跨设备

成绩管理系统是典型的单人本地操作工具,Tkinter的Treeview、Toplevel、messagebox刚好覆盖需求,而且PyInstaller打包exe时不用折腾一堆Qt动态库。顺带提醒环境问题:Windows下官方Python安装包默认带Tkinter;Linux如果报No module named tkinter,需要先装python3-tk。matplotlib不是标准库,记得pip install matplotlib。这几个点不处理好,代码写得再对也跑不起来。

2. 数据层设计:JSON存储 + 三层结构,读写都清爽

2.1 JSON、CSV、SQLite三个方案怎么权衡

存储方案是很多初学者拿到项目就忽略的部分,我第一版用的CSV,改到后面差点想放弃。CSV只有一张扁平的二维表,加一门课等于加一列,想存“每个学生科目数量不一样”的数据就得补一堆空列,读取时还得处处防中文乱码,体验真的不好。

SQLite功能强、性能好,但意味着要写SQL语句、维护表结构,对一个小型成绩工具来说有点杀鸡用牛刀。最终我选了JSON。原因很直接:JSON和Python的数据结构几乎一一对应,列表套字典、字典套列表,json.loadjson.dump两个函数就能完成读写,代码量最小。几百个学生、几门课程的数据量,性能完全不是瓶颈。真到上万学生、多用户并发写入的那天,再迁到SQLite也不迟,但那已经是另一个量级的需求了。

2.2 数据结构与读写封装

我设计的数据结构是三层嵌套:

{ "students": [ { "student_id": "2024001", "name": "张伟", "scores": {"语文": 92, "数学": 85, "英语": 78, "物理": 81} } ] }

最外层是students列表,代表所有学生;每个学生是一个字典,固定字段student_idnamescores再嵌套一层字典,科目名做key、分数做value。这样加一门科目,只是往某个学生的scores里加一个key,存储层完全不用动,后续扩展班级、学年字段同理。

读写封装成两个函数,整个项目统一调用:

import json DATA_FILE = "grades.json" def load_data(): try: with open(DATA_FILE, "r", encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {"students": []} def save_data(data): with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

这里有两个容易被忽略的细节。ensure_ascii=False保证中文以明文形式写入文件,而不是转成一长串\uXXXX转义序列,否则打开grades.json想人工校对一下都费劲。indent=2让JSON格式化输出,排查数据问题时比压缩成一行舒服太多。load_data里同时捕获FileNotFoundErrorJSONDecodeError,首次运行没有文件、或者文件不小心被改坏时,程序都能正常启动,而不是直接抛异常闪退。

3. 界面层搭建:Treeview表格 + 功能分区,交互要直观

3.1 主窗口布局思路

GUI界面最忌讳把所有控件堆在一堆。我按“上查询、中表格、右操作、下统计”四个区来分:

  • 顶部:学号/姓名搜索框 + 查询、重置按钮。
  • 中部主体:成绩表格,用ttk.Treeview实现。
  • 右侧:添加、修改、删除、刷新四个操作按钮,竖排。
  • 底部:总人数、平均分、及格率统计信息,以及一个科目下拉框用来切换图表。

骨架代码大概是:

import tkinter as tk from tkinter import ttk, messagebox root = tk.Tk() root.title("Python学生成绩管理系统") root.geometry("980x600") frame_top = ttk.Frame(root) frame_top.pack(fill="x", padx=10, pady=(10, 5)) frame_mid = ttk.Frame(root) frame_mid.pack(fill="both", expand=True, padx=10) frame_right = ttk.Frame(frame_mid) frame_right.pack(side="right", fill="y", padx=(10, 0)) tree_frame = ttk.Frame(frame_mid) tree_frame.pack(side="left", fill="both", expand=True)

packgrid不是不能混用,但建议一个容器内只选一种布局,不然调间距时控件会莫名其妙对不齐。我自己习惯顶层用pack分大区,Treeview内部用grid做列控制,分工明确,后面加组件也不容易乱。

3.2 表格加载、刷新和选中状态

Treeview是Tkinter里做表格最顺手的控件。定义列和插入行的核心逻辑:

columns = ("student_id", "name", "chinese", "math", "english", "avg") tree = ttk.Treeview(tree_frame, columns=columns, show="headings") for col in columns: tree.heading(col, text=col) tree.column(col, width=100, anchor="center") tree.pack(fill="both", expand=True)

刷新表格时我踩过一个特别典型的坑:每次刷新直接tree.insert往里加数据,结果旧行不清掉,表格越积越长。正确做法是刷新前先清空所有子节点:

def refresh_table(data): tree.delete(*tree.get_children()) for stu in data["students"]: scores = stu["scores"] avg = round(sum(scores.values()) / len(scores), 1) tree.insert("", "end", iid=stu["student_id"], values=( stu["student_id"], stu["name"], scores.get("语文", 0), scores.get("数学", 0), scores.get("英语", 0), avg ))

这里有个关键细节:iid直接用学号,而不是自增数字。Treeview的每个item都有全局唯一标识,如果用1、2、3这种索引,删掉第一行后再插入新数据,复用的iid会带着旧的选中状态,界面很容易出现“明明删了A,结果高亮的是B”的诡异问题。用学号做iid天然保证唯一,后面删除、修改时也能通过tree.selection()先拿到学号,再映射到真实数据,逻辑链路特别清晰。

4. 功能层实现:增删改查、排序、统计

4.1 录入和修改时,数据校验不能省

很多初学朋友做添加功能,就是简单把Entry里的字符串直接塞进列表。但GUI里的用户输入是不可控的,成绩填成“abc”或者填了150分,程序要么报错吓人,要么留下脏数据。我在添加和修改的弹窗里统一做了三道校验:成绩必须是数字、必须在0到100之间、学号不能重复。

def confirm_add(): sid = entry_id.get().strip() name = entry_name.get().strip() try: chinese = float(entry_chinese.get().strip()) math = float(entry_math.get().strip()) english = float(entry_english.get().strip()) except ValueError: messagebox.showerror("输入错误", "成绩必须是数字") return if not (0 <= chinese <= 100 and 0 <= math <= 100 and 0 <= english <= 100): messagebox.showerror("输入错误", "成绩必须在0到100之间") return if any(s["student_id"] == sid for s in data["students"]): messagebox.showerror("输入错误", "该学号已存在") return data["students"].append( {"student_id": sid, "name": name, "scores": {"语文": chinese, "数学": math, "英语": english}} ) save_data(data) refresh_ui() dialog.destroy()

修改功能的逻辑类似,先根据选中的行拿到学号,再对原数据整体覆写,不能只改scores,学生改名的情况也要一并处理。每次成功操作后立即调用save_data,保证数据落盘,这个习惯后面会救命。

4.2 实时统计:平均分、最高/最低分、及格率

每完成一次增删改,我都会走一遍refresh_ui(),它内部的职责很单一:重新加载表格、重新计算统计、重新绘制图表。统计部分集中在一个函数里:

def compute_stats(data): subjects = set() for stu in data["students"]: subjects.update(stu["scores"].keys()) stats = {} for subject in subjects: scores = [stu["scores"].get(subject, 0) for stu in data["students"]] if scores: passed = [s for s in scores if s >= 60] stats[subject] = { "avg": round(sum(scores) / len(scores), 1), "max": max(scores), "min": min(scores), "pass_rate": round(len(passed) / len(scores) * 100, 1) } return stats

这个设计最大的好处是科目不写死。统计时自动从所有学生的scores键集合里取科目,以后在录入界面加一个“物理”输入框,这段统计代码一行都不用改。及格线60分目前写死在函数里,对这个场景够用;要支持不同及格线,提成全局变量或者配置项即可。

4.3 查询与排序

查询功能按学号或姓名过滤结果,重新渲染表格,核心就是一行列表推导式:

def search_students(): keyword = entry_search.get().strip() if not keyword: refresh_ui() return filtered = [ stu for stu in data["students"] if keyword in stu["student_id"] or keyword in stu["name"] ] refresh_table({"students": filtered})

排序直接用Python内置的sorted,按平均分降序排列后刷新:

def sort_by_avg_desc(): sorted_students = sorted( data["students"], key=lambda stu: sum(stu["scores"].values()) / len(stu["scores"]), reverse=True ) refresh_table({"students": sorted_students})

排名其实就是enumerate完的序号,把它作为表格第一列显示,老师导出或打印时名次一目了然。想把排序做成“点击列名触发”,只需要把函数绑到列标题上:tree.heading("avg", command=sort_by_avg_desc),一行代码的事。

5. 把图表嵌进GUI:matplotlib可视化成绩分布

5.1 FigureCanvasTkAgg接入方式

可视化是这个项目最亮眼的部分。matplotlib默认把图画到独立窗口,要嵌进Tkinter主窗口,需要FigureCanvasTkAgg这个桥接组件:

import matplotlib matplotlib.use("TkAgg") from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure fig = Figure(figsize=(5, 3), dpi=100) canvas = FigureCanvasTkAgg(fig, master=chart_frame) canvas.get_tk_widget().pack(fill="both", expand=True) def draw_distribution(subject): scores = [stu["scores"].get(subject, 0) for stu in data["students"]] fig.clear() ax = fig.add_subplot(111) ax.hist(scores, bins=10, range=(0, 100), edgecolor="white") ax.set_title(f"{subject}成绩分布") ax.set_xlabel("分数") ax.set_ylabel("人数") canvas.draw()

两个细节必须注意。一是fig.clear()每次先清空画布再重画,不然多次刷新时图形会叠加得乱七八糟。二是canvas.draw()是更新界面的一步,漏掉的话改了数据图表却纹丝不动,排查时很容易怀疑人生。

5.2 中文显示与图表联动

matplotlib默认字体不支持中文,直接把“成绩分布”写进标题,显示出来就是一个个方框。解决方法是显式指定中文字体:

plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False

如果你在Mac或Linux上跑,SimHei可能不存在,建议写成备选列表:["PingFang SC", "WenQuanYi Zen Hei", "SimHei"],程序会按顺序找第一个可用的字体。

图表联动我做成这样:底部有一个科目下拉框,选“数学”就调用draw_distribution("数学"),右侧图表立即更新为数学成绩分布。除了直方图,及格率场景更适合饼图,把ax.hist换成ax.pie逻辑完全一样,把“及格”和“不及格”的人数按比例画出来即可。我在每次增删改后也会自动重画一次,保证表格和图表数据始终同步,演示效果会明显上一个台阶。

6. 实际开发中踩过的坑和排查过程

6.1 Treeview刷新后数据越加越多

第一版刷新表格时没先执行tree.delete(*tree.get_children()),结果每次增删改后,表格下方不断追加重复行。排查时我在refresh_table开头加了一行print(len(tree.get_children())),发现数量只增不减,才意识到Tkinter不会自动清理旧行。后来统一改成“先删光所有子节点再插入”,问题彻底消失。这个坑很隐蔽,因为程序不报错,只有看数据行数才会发现。

6.2 保存时机不对,崩溃丢数据

最初我图省事,只在关闭窗口时调用save_data,结果开发中一次死机,半天录入的测试数据全没了,当时真的头大。后来改成每次增、删、改成功后立即保存,多几次磁盘写入但对几百条数据完全无感。更稳的做法是先写临时文件再替换原文件,防止写入一半断电把整个JSON文件搞坏。如果你要把系统给多人用,建议补上这层保护,别问我怎么知道的。

6.3 打包exe后图表区域白屏

开发时一切正常,用PyInstaller打成单文件exe后,图表区域白屏,程序也不报错。排查一圈,问题出在matplotlib的数据文件没有被收集进包。解决办法是打包命令加--collect-all matplotlib

pyinstaller -w -F --collect-all matplotlib --hidden-import tkinter main.py

-w表示不显示控制台黑框,-F表示单文件。打包后exe体积会变大,因为matplotlib整个被收进去了,但换来的是双击就能运行,省去了目标机器装Python环境的问题,对于课设演示和给不懂技术的老师用,非常值得。

6.4 弹窗焦点错乱和重复弹出

添加/修改用的Toplevel弹窗,如果不设置transient(root)grab_set(),弹窗可能跑到主窗口后面,或者主窗口还能继续操作,逻辑上容易出现数据不一致。另外用户快速连点“添加”按钮,可能会一次弹出好几个窗口。我给按钮绑定的命令里加了判断:弹窗实例已存在就不再创建新窗口。这些小细节在演示时特别加分,老师操作时会明显觉得“这软件挺稳”。

做完这套系统,我自己最大的体会是:GUI程序里真正影响使用体验的,不是控件样式多花哨,而是数据不丢、操作有反馈、刷新后界面和实际状态一致。这套代码后面我又加了导出Excel的功能——用pandas把学生成绩和统计结果直接写进一张工作表,几行代码的事,但汇报时特别有用。如果你也要做类似的课设或者自用工具,建议先花时间把数据模型定义好,再去做界面和图表,后面会少走很多弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询