简介:这是一份面向Python初学者与课程设计学习者的学生成绩管理系统完整源码包,采用Python结合GUI图形界面开发,实现学生成绩的录入、查询、修改、删除与统计等核心功能,适合作为编程入门练手项目或毕业设计参考。压缩包共19个文件,约14KB,以6个ui界面文件、5个xml配置、5个txt数据文本为主,另含1个py主程序、1个iml工程文件及gitignore等辅助文件,界面与逻辑分离,结构清晰便于阅读。资源围绕Tkinter或PyQt界面设计、SQLite数据存储与增删改查逻辑展开,读者可借此理解窗口控件布局、事件绑定、数据校验与成绩统计的实现思路,并参考其模块划分方式自行扩展导出、排序等功能。目前已有17859人学习下载,热度较高,适合希望快速上手Python GUI与数据库综合应用的学习者参考借鉴。
1. 从一份“能跑起来”的成绩单说起:Python学生成绩管理系统+GUI可视化界面到底解决什么问题
很多一线教学场景里,成绩管理还停留在“Excel 表格 + 手动筛选”的阶段。一个班几十号人还好,一旦到了年级几百人、科目十几门,光是统计平均分、排名、及格率就能耗掉一整个下午。更麻烦的是,数据一多,公式引用错位、单元格覆盖、版本冲突这些玄学问题就会集中爆发。Python学生成绩管理系统+GUI可视化界面这个方向,本质上就是用代码把“录入—存储—统计—展示”这条链路固化下来,让重复劳动交给程序,人只负责决策。它适合两类人:一类是刚学完 Python 基础、想找一个完整项目练手的开发者;另一类是需要快速搭建内部成绩管理工具的一线教师或教务人员。GUI 可视化界面在这里不是装饰,而是降低使用门槛的关键——不是每个人都能对着命令行敲参数,但每个人都会点按钮、看图表。这一章先把边界划清楚:系统管的是结构化成绩数据,核心诉求是增删改查、统计分析和可视化呈现,不涉及复杂的权限体系或分布式部署。
2. 技术选型与数据层设计:为什么用 Tkinter + SQLite 而不是 Web 框架
2.1 桌面端 GUI 的选型逻辑:Tkinter 够用,PyQt 更重
做学生成绩管理系统,第一个要定的是界面技术栈。常见做法有三种:Tkinter、PyQt/PySide、Web 框架(Flask/Django + 前端)。如果目标是“单机运行、零部署成本、快速出活”,Tkinter 是首选。它是 Python 标准库自带的,不需要额外安装,打包成 exe 也简单。PyQt 的控件更丰富、样式更现代,但学习曲线陡,打包体积大,对一个小型成绩管理工具来说有点杀鸡用牛刀。Web 框架适合多人协作场景,但需要处理浏览器兼容、服务部署、并发访问,复杂度直接上一个量级。我一般会这样判断:如果用户就在本机操作、数据量在万条以内、不需要远程访问,Tkinter + SQLite 的组合是最稳的。下面是一个最小可运行的 Tkinter 窗口骨架,先把主循环跑通再往上叠功能。
import tkinter as tk from tkinter import ttk class GradeApp: def __init__(self, root): self.root = root self.root.title("学生成绩管理系统") self.root.geometry("900x600") # 用 Notebook 做多标签页:录入、查询、统计、图表 self.notebook = ttk.Notebook(root) self.notebook.pack(fill=tk.BOTH, expand=True) self._build_input_tab() self._build_query_tab() def _build_input_tab(self): frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="成绩录入") ttk.Label(frame, text="学号").grid(row=0, column=0, padx=8, pady=8) self.entry_id = ttk.Entry(frame) self.entry_id.grid(row=0, column=1, padx=8, pady=8) ttk.Label(frame, text="姓名").grid(row=1, column=0, padx=8, pady=8) self.entry_name = ttk.Entry(frame) self.entry_name.grid(row=1, column=1, padx=8, pady=8) def _build_query_tab(self): frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="成绩查询") self.tree = ttk.Treeview(frame, columns=("id", "name", "score"), show="headings") self.tree.heading("id", text="学号") self.tree.heading("name", text="姓名") self.tree.heading("score", text="成绩") self.tree.pack(fill=tk.BOTH, expand=True) if __name__ == "__main__": root = tk.Tk() app = GradeApp(root) root.mainloop()这段代码做了三件事:创建主窗口、用 Notebook 组织多标签页、在录入页放置输入控件。geometry("900x600")设定初始窗口大小,fill=tk.BOTH, expand=True让控件随窗口缩放。注意ttk是 themed tkinter,比原生 tk 控件更接近系统风格。参数上,padx/pady控制间距,grid布局比pack更适合表单类界面。跑通这个骨架后,再把数据库操作挂到按钮事件上。
2.2 SQLite 表结构设计:三张表覆盖绝大多数场景
数据层用 SQLite 的理由很直接:单文件、零配置、Python 内置支持。表结构设计上,我一般会拆成三张表——学生表、课程表、成绩表。学生表存学号、姓名、班级;课程表存课程编号、课程名、学分;成绩表存学号、课程编号、分数。这样设计的好处是避免数据冗余,一个学生选多门课、一门课被多个学生选,都能用关联查询搞定。下面建表语句可以直接在 SQLite 里执行。
import sqlite3 def init_db(db_path="grades.db"): conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS students ( student_id TEXT PRIMARY KEY, name TEXT NOT NULL, class_name TEXT ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS courses ( course_id TEXT PRIMARY KEY, course_name TEXT NOT NULL, credit REAL DEFAULT 1.0 ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS scores ( student_id TEXT, course_id TEXT, score REAL CHECK(score >= 0 AND score <= 100), PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES students(student_id), FOREIGN KEY (course_id) REFERENCES courses(course_id) ) """) conn.commit() conn.close()CHECK(score >= 0 AND score <= 100)是数据库层面的约束,防止脏数据写入。复合主键(student_id, course_id)保证一个学生一门课只有一条成绩记录。外键约束在 SQLite 里默认不开启,需要执行PRAGMA foreign_keys = ON;,这个坑后面会细说。credit字段用 REAL 类型,因为学分可能是 1.5、2.5 这种小数。建完表后,插入数据用INSERT OR REPLACE可以避免重复录入报错。
2.3 数据访问层封装:把 SQL 关进笼子里
直接在界面代码里写 SQL 是维护噩梦。我习惯单独封一个GradeDAO类,所有数据库操作走它。这样换数据库、加缓存、做单元测试都方便。下面是一个精简版实现。
class GradeDAO: def __init__(self, db_path="grades.db"): self.conn = sqlite3.connect(db_path) self.conn.execute("PRAGMA foreign_keys = ON") def add_student(self, sid, name, cls): self.conn.execute( "INSERT OR REPLACE INTO students VALUES (?, ?, ?)", (sid, name, cls) ) self.conn.commit() def add_score(self, sid, cid, score): self.conn.execute( "INSERT OR REPLACE INTO scores VALUES (?, ?, ?)", (sid, cid, score) ) self.conn.commit() def query_scores(self, sid=None): sql = """SELECT s.student_id, st.name, s.course_id, c.course_name, s.score FROM scores s JOIN students st ON s.student_id = st.student_id JOIN courses c ON s.course_id = c.course_id""" if sid: sql += " WHERE s.student_id = ?" return self.conn.execute(sql, (sid,)).fetchall() return self.conn.execute(sql).fetchall()INSERT OR REPLACE在 SQLite 里是“有则替换、无则插入”的语义,适合成绩修正场景。query_scores用 JOIN 把三张表串起来,返回结果可以直接喂给 Treeview 显示。注意PRAGMA foreign_keys = ON必须每次连接后执行,否则外键约束形同虚设。参数化查询用?占位符,避免 SQL 注入,虽然桌面端风险低,但习惯要养好。
3. 从录入到可视化:把功能一块块拼起来
3.1 成绩录入与校验:别让脏数据进库
录入界面最容易翻车的地方是校验。用户可能输入空学号、非数字成绩、超范围分数。我的做法是在按钮回调里做三层校验:非空检查、类型转换、范围判断。下面是一个录入按钮的完整实现。
def on_submit(self): sid = self.entry_id.get().strip() name = self.entry_name.get().strip() score_str = self.entry_score.get().strip() if not sid or not name or not score_str: messagebox.showwarning("提示", "学号、姓名、成绩均不能为空") return try: score = float(score_str) except ValueError: messagebox.showerror("错误", "成绩必须是数字") return if not (0 <= score <= 100): messagebox.showerror("错误", "成绩必须在 0 到 100 之间") return self.dao.add_student(sid, name, self.entry_class.get().strip()) self.dao.add_score(sid, self.course_var.get(), score) messagebox.showinfo("成功", "成绩录入完成") self.refresh_tree()strip()去掉首尾空格,防止“ 123”这种输入绕过校验。float()转换失败会抛 ValueError,用 try/except 捕获后弹窗提示。范围判断放在类型转换之后,顺序不能反。refresh_tree()是刷新查询页表格的方法,保证录入后立刻能看到新数据。这里有个细节:如果课程下拉框没选,course_var.get()返回空字符串,插入时会触发外键约束失败,所以最好在提交前也校验课程是否已选。
3.2 查询与筛选:Treeview 动态刷新与条件组合
查询页的核心是 Treeview 控件,它负责表格展示。数据量大时,一次性加载所有记录会卡顿,所以我会加一个简单的筛选条件——按学号或课程过滤。下面代码演示如何把 DAO 返回的数据填充到 Treeview,并支持清空重填。
def refresh_tree(self, sid_filter=None): for row in self.tree.get_children(): self.tree.delete(row) rows = self.dao.query_scores(sid_filter) for r in rows: self.tree.insert("", tk.END, values=r)get_children()返回所有行 ID,循环delete清空表格。insert("", tk.END, values=r)把元组数据插入末尾。如果要做分页,可以加LIMIT和OFFSET,但万条以内没必要。筛选条件组合可以用字典拼 SQL,但更稳的做法是写多个 DAO 方法,避免字符串拼接。比如按课程筛选就加一个query_by_course(cid),内部用参数化查询。
3.3 统计指标计算:平均分、排名、及格率一次算清
统计功能是成绩管理系统的价值核心。常见指标有:单科平均分、学生总分排名、及格率、分数段分布。这些用 SQL 聚合函数就能算,不必把数据拉到 Python 里循环。下面给出几个典型查询。
def course_stats(self, cid): sql = """SELECT AVG(score), MAX(score), MIN(score), SUM(CASE WHEN score >= 60 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) FROM scores WHERE course_id = ?""" return self.conn.execute(sql, (cid,)).fetchone() def student_ranking(self): sql = """SELECT s.student_id, st.name, SUM(s.score) AS total FROM scores s JOIN students st ON s.student_id = st.student_id GROUP BY s.student_id ORDER BY total DESC""" return self.conn.execute(sql).fetchall()AVG/MAX/MIN一次返回三个统计值。及格率用SUM(CASE WHEN...)配合COUNT(*)计算,乘1.0是为了强制浮点除法,否则整数除法会截断。排名查询用GROUP BY加ORDER BY total DESC,简单直接。如果学分权重不同,可以把SUM(s.score)换成SUM(s.score * c.credit),但需要再 JOIN 课程表。
3.4 用 Matplotlib 嵌入 GUI:把分数分布画出来
可视化是 GUI 的加分项。Matplotlib 可以嵌入 Tkinter,用FigureCanvasTkAgg把图表塞进窗口。下面是一个分数段柱状图的实现。
import matplotlib matplotlib.use("TkAgg") from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg def draw_histogram(self, parent, scores): fig = Figure(figsize=(5, 3), dpi=100) ax = fig.add_subplot(111) ax.hist(scores, bins=[0, 60, 70, 80, 90, 100], edgecolor="black") ax.set_title("分数段分布") ax.set_xlabel("分数") ax.set_ylabel("人数") canvas = FigureCanvasTkAgg(fig, master=parent) canvas.draw() canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)matplotlib.use("TkAgg")指定后端,必须在导入 pyplot 之前设置。bins参数定义分段边界,这里按不及格、60-70、70-80、80-90、90-100 划分。FigureCanvasTkAgg把 Figure 转成 Tkinter 控件,pack后随窗口缩放。注意中文字体问题:Matplotlib 默认不支持中文,需要设置plt.rcParams["font.sans-serif"] = ["SimHei"],否则标题会显示方块。
4. 避坑与排查:那些让我熬夜的报错和玄学问题
4.1 中文乱码:数据库、界面、图表三处都要管
现象:录入中文姓名后,查询显示问号或方块。原因:SQLite 默认编码是 UTF-8,但 Windows 终端或 Tkinter 字体可能不支持。解决:连接数据库时加conn.text_factory = str,Tkinter 控件指定font=("Microsoft YaHei", 10),Matplotlib 设置rcParams["font.sans-serif"]。三处都设好,中文才能正常显示。
4.2 外键约束不生效:PRAGMA 必须每次连接执行
现象:删除了学生表记录,成绩表里还留着孤儿数据。原因:SQLite 外键默认关闭,PRAGMA foreign_keys = ON只在当前连接有效。解决:在 DAO 的__init__里每次连接后立即执行该 PRAGMA,不要指望建表时写了 FOREIGN KEY 就自动生效。
4.3 Treeview 数据不刷新:忘了先清空再插入
现象:反复查询后表格出现重复行。原因:insert是追加操作,没有清空旧数据。解决:每次刷新前用get_children()遍历删除,或者用self.tree.delete(*self.tree.get_children())一行搞定。这个坑新手必踩,血泪经验。
4.4 打包成 exe 后找不到数据库文件:路径要用绝对路径
现象:PyInstaller 打包后运行,提示grades.db不存在。原因:打包后工作目录变了,相对路径失效。解决:用os.path.dirname(os.path.abspath(__file__))获取脚本所在目录,再拼接数据库路径。或者把数据库放在用户目录下,用os.path.expanduser("~")定位。
4.5 界面卡死:耗时操作别放主线程
现象:点击“统计”按钮后窗口无响应。原因:大量数据计算阻塞了 Tkinter 主循环。解决:用threading.Thread把耗时操作丢到后台线程,算完再用root.after(0, callback)回主线程更新界面。注意 Tkinter 控件只能在主线程操作,子线程里不要直接改界面。
5. 进阶技巧:让系统从“能用”变成“好用”
5.1 用 Pandas 做批量导入导出,告别手动录入
单个录入适合零星修正,批量场景还是 Excel 导入导出更实际。Pandas 的read_excel和to_excel能直接对接。下面是一个导入函数,把 Excel 里的成绩批量写进 SQLite。
import pandas as pd def import_from_excel(self, filepath): df = pd.read_excel(filepath, dtype={"学号": str}) for _, row in df.iterrows(): self.dao.add_student(row["学号"], row["姓名"], row.get("班级", "")) self.dao.add_score(row["学号"], row["课程编号"], float(row["成绩"])) self.refresh_tree()dtype={"学号": str}防止学号被 Pandas 自动转成数字丢失前导零。iterrows()逐行处理,数据量大时可以用to_sql直接写库,但需要 SQLAlchemy 引擎。导出更简单:pd.DataFrame(rows).to_excel("导出.xlsx", index=False)。
5.2 成绩修改留痕:加一张操作日志表
成绩修改需要可追溯,这是实际使用中的硬需求。加一张audit_log表,记录谁在什么时候改了什么。
def log_change(self, sid, cid, old_score, new_score): self.conn.execute( "INSERT INTO audit_log VALUES (?, ?, ?, ?, datetime('now'))", (sid, cid, old_score, new_score) ) self.conn.commit()datetime('now')是 SQLite 内置函数,返回 UTC 时间。如果要本地时间,用datetime('now', 'localtime')。日志表只增不改,查询时按学号过滤即可还原修改历史。
5.3 用 PyInstaller 打包:一条命令生成独立 exe
最后一步是交付。PyInstaller 可以把整个项目打包成单个 exe,用户双击就能跑。
pyinstaller --onefile --windowed --name GradeManager main.py--onefile生成单个文件,--windowed隐藏命令行窗口,--name指定输出名。打包后数据库文件不会自动带上,需要在代码里判断sys.frozen属性,把数据库路径指向 exe 同级目录或用户目录。这个技巧我踩过坑:打包后第一次运行要初始化数据库,否则会报“表不存在”。
5.4 验证方法:用单元测试守住数据层
GUI 不好测,但 DAO 层可以。用unittest写几个用例,覆盖增删改查和统计。
import unittest class TestGradeDAO(unittest.TestCase): def setUp(self): self.dao = GradeDAO(":memory:") self.dao.conn.execute("CREATE TABLE students (student_id TEXT PRIMARY KEY, name TEXT, class_name TEXT)") self.dao.conn.execute("CREATE TABLE courses (course_id TEXT PRIMARY KEY, course_name TEXT, credit REAL)") self.dao.conn.execute("CREATE TABLE scores (student_id TEXT, course_id TEXT, score REAL, PRIMARY KEY (student_id, course_id))") def test_add_and_query(self): self.dao.add_student("001", "张三", "一班") self.dao.add_score("001", "C01", 85.5) rows = self.dao.query_scores("001") self.assertEqual(len(rows), 1) self.assertAlmostEqual(rows[0][4], 85.5)用:memory:建内存数据库,测试完自动销毁,不污染文件。setUp里建表,每个用例独立运行。assertAlmostEqual处理浮点比较。这套测试跑通,数据层基本稳了。
我自己的习惯是:先把 DAO 测通,再接界面。界面出问题多半是回调没绑对或控件状态没同步,跟数据层无关。这样排查起来方向明确,不会眉毛胡子一把抓。希望帮到你。
本文还有配套的精品资源,点击获取