Python选课系统源码实战:从数据库设计到Tkinter界面完整实现
简介这份资源是基于Python开发的选课信息管理系统完整源码面向教育机构信息化建设者、计算机专业学生及Python初学者用于快速搭建学生选课与信息管理平台。压缩包共147个文件约81.61MB包含81个DLL动态链接库、22个QM翻译文件、14个PYD与9个PYC编译模块、9个PY源码、2个DB数据库、2个JPG界面图片及QRC、manifest等配置资源覆盖运行依赖、界面资源与数据持久化各环节。系统按学生选课主函数、显示、主界面、查询和教师端等模块划分教师端可管理课程并查看选课情况查询模块便于检索课程信息数据库文件存储课程表、选课记录与教师信息。已有306人学习下载适合作为课程设计或毕业设计参考帮助读者理解模块化架构、Qt界面与数据库协同方式并在此基础上进行功能扩展与二次开发。1. 从一份选课系统源码说起Python 课程设计到底该做成什么样每年学期中段计算机相关专业的课程设计题目里选课信息管理系统出现的频率高得离谱。原因很直接它同时踩中了数据库增删改查、权限控制、并发冲突、界面交互这几个教学重点又不像电商系统那样庞大到做不完。但真正动手时大部分人会卡在同一个地方——网上搜到的Python 选课信息管理系统源码要么是几十行的控制台玩具要么是套了 Django 却连选课冲突都没处理的半成品。这篇笔记要解决的就是这件事从零搭一个能跑、能演示、能写进课程设计报告的选课信息管理系统用 Python 做后端逻辑SQLite 做数据存储Tkinter 或 Flask 做交互层。适合正在做课程设计的学生也适合想拿一个完整小项目练手 Python 语法、数据库操作和基本架构分层的人。核心不是把代码堆出来而是搞清楚每个模块为什么这么设计、参数怎么定、哪里最容易翻车。2. 选课系统的数据模型与分层设计先把表结构定死再写代码2.1 四张核心表撑起整个系统选课系统的本质是三个实体之间的关系学生、课程、选课记录。再加一张用户表做登录鉴权四张表就够了。很多人一上来就写界面写到一半发现数据结构不对回头改表血泪经验。-- 学生表 CREATE TABLE students ( student_id TEXT PRIMARY KEY, -- 学号如 2023010101 name TEXT NOT NULL, password TEXT NOT NULL DEFAULT 123456, major TEXT, max_credits INTEGER DEFAULT 25 -- 每学期选课学分上限 ); -- 课程表 CREATE TABLE courses ( course_id TEXT PRIMARY KEY, -- 课程号如 CS101 course_name TEXT NOT NULL, teacher TEXT, credits REAL DEFAULT 2.0, -- 学分支持 0.5 的粒度 capacity INTEGER DEFAULT 60, -- 课容量 enrolled INTEGER DEFAULT 0, -- 已选人数冗余字段 schedule TEXT, -- 上课时间如 周一3-4节 semester TEXT DEFAULT 2024-2025-1 ); -- 选课记录表 CREATE TABLE enrollments ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id TEXT NOT NULL, course_id TEXT NOT NULL, enroll_time TEXT DEFAULT (datetime(now, localtime)), status TEXT DEFAULT active, -- active / dropped FOREIGN KEY (student_id) REFERENCES students(student_id), FOREIGN KEY (course_id) REFERENCES courses(course_id), UNIQUE(student_id, course_id) -- 防止重复选同一门课 ); -- 管理员表 CREATE TABLE admins ( admin_id TEXT PRIMARY KEY, password TEXT NOT NULL );这里有几个设计决策值得说清楚。enrolled字段是冗余的理论上可以通过COUNT(*)从 enrollments 表算出来但每次查课程列表都做聚合查询课程数量一多就慢。用冗余字段换查询性能代价是选课和退课时必须同步更新这个一致性由后面的业务逻辑保证。UNIQUE(student_id, course_id)这个约束是最后一道防线。应用层会做重复选课检查但万一并发场景下两个请求同时通过了检查数据库约束会兜住。别省这一行。credits用 REAL 而不是 INTEGER因为有些学校有 0.5 学分的课程。如果你确定学校只有整数学分改成 INTEGER 也没问题但 REAL 更保险。2.2 三层架构为什么不能把所有代码塞一个文件课程设计最常见的翻车方式就是一个.py文件写到底界面、业务逻辑、SQL 语句混在一起。写到 300 行以后改一个查询条件要在几百行里翻找。我一般会分成三层数据访问层dao.py只负责和数据库打交道每个函数对应一组 SQL 操作业务逻辑层service.py处理选课冲突检测、学分上限校验、容量判断界面层ui.py 或 app.py只做展示和用户输入收集不直接碰数据库# dao.py - 数据访问层 import sqlite3 DB_PATH course_system.db def get_connection(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row # 让查询结果支持按列名访问 conn.execute(PRAGMA foreign_keys ON) # SQLite 默认不开外键必须手动开 return conn def query_courses(keywordNone, semesterNone): conn get_connection() sql SELECT * FROM courses WHERE 11 params [] if keyword: sql AND (course_name LIKE ? OR teacher LIKE ?) params.extend([f%{keyword}%, f%{keyword}%]) if semester: sql AND semester ? params.append(semester) rows conn.execute(sql, params).fetchall() conn.close() return [dict(r) for r in rows] def insert_enrollment(student_id, course_id): conn get_connection() try: conn.execute( INSERT INTO enrollments (student_id, course_id) VALUES (?, ?), (student_id, course_id) ) conn.execute( UPDATE courses SET enrolled enrolled 1 WHERE course_id ?, (course_id,) ) conn.commit() return True except sqlite3.IntegrityError: conn.rollback() return False finally: conn.close()PRAGMA foreign_keys ON这行是必须的。SQLite 出于兼容性考虑默认不启用外键约束不写这行你建表时定义的外键就是摆设。这个坑我踩过不止一次数据删了才发现关联记录还在。conn.row_factory sqlite3.Row让查询结果可以用row[course_name]这种方式访问比记列索引号靠谱得多。2.3 选课冲突检测时间冲突和容量判断的代码实现选课系统最核心的业务逻辑就是冲突检测。需要检查三件事课程是否已满、学生是否已选过、上课时间是否冲突。# service.py - 业务逻辑层 from dao import get_connection, query_courses, insert_enrollment def check_time_conflict(schedule_a, schedule_b): 检测两个上课时间是否冲突schedule 格式如 周一3-4节 if not schedule_a or not schedule_b: return False day_a, periods_a parse_schedule(schedule_a) day_b, periods_b parse_schedule(schedule_b) if day_a ! day_b: return False # 检查节次是否有重叠 return bool(set(periods_a) set(periods_b)) def parse_schedule(schedule): 把 周一3-4节 解析成 (周一, [3, 4]) day schedule[:2] # 取周一 period_str schedule[2:].replace(节, ) if - in period_str: start, end period_str.split(-) return day, list(range(int(start), int(end) 1)) return day, [int(period_str)] def enroll_course(student_id, course_id): 选课主逻辑返回 (成功与否, 提示信息) conn get_connection() try: # 1. 检查课程是否存在且有容量 course conn.execute( SELECT * FROM courses WHERE course_id ?, (course_id,) ).fetchone() if not course: return False, 课程不存在 if course[enrolled] course[capacity]: return False, 课程已满 # 2. 检查是否已经选过 existing conn.execute( SELECT * FROM enrollments WHERE student_id ? AND course_id ? AND status active, (student_id, course_id) ).fetchone() if existing: return False, 已选过该课程 # 3. 检查学分上限 student conn.execute( SELECT * FROM students WHERE student_id ?, (student_id,) ).fetchone() current_credits conn.execute( SELECT COALESCE(SUM(c.credits), 0) as total FROM enrollments e JOIN courses c ON e.course_id c.course_id WHERE e.student_id ? AND e.status active, (student_id,) ).fetchone()[total] if current_credits course[credits] student[max_credits]: return False, f超出学分上限已选{current_credits}学分上限{student[max_credits]} # 4. 检查时间冲突 my_courses conn.execute( SELECT c.schedule FROM enrollments e JOIN courses c ON e.course_id c.course_id WHERE e.student_id ? AND e.status active, (student_id,) ).fetchall() for row in my_courses: if check_time_conflict(row[schedule], course[schedule]): return False, f与已选课程时间冲突{row[schedule]} # 5. 全部通过执行选课 conn.execute( INSERT INTO enrollments (student_id, course_id) VALUES (?, ?), (student_id, course_id) ) conn.execute( UPDATE courses SET enrolled enrolled 1 WHERE course_id ?, (course_id,) ) conn.commit() return True, 选课成功 except Exception as e: conn.rollback() return False, f系统错误{str(e)} finally: conn.close()这段代码里parse_schedule函数假设时间格式是固定的周X N-M节。实际场景中上课时间格式可能五花八门有的用1-2节表示有的用08:00-09:40。如果你的数据源格式不统一建议在导入阶段就做标准化别留到业务逻辑里处理。学分上限的检查用了COALESCE(SUM(...), 0)因为学生如果一门课都没选SUM 返回 NULL不加 COALESCE 会报类型错误。这种细节不写测试很难发现。3. 用 Tkinter 搭一个能演示的图形界面从登录到选课完整流程3.1 登录窗口与主界面切换课程设计答辩时老师第一眼看到的是界面。Tkinter 虽然丑但胜在 Python 自带不需要额外装东西适合交作业场景。如果你愿意多花点时间用 Flask 做个 Web 界面会更出彩但 Tkinter 的代码量更可控。# ui.py - 基于 Tkinter 的界面层 import tkinter as tk from tkinter import ttk, messagebox from service import enroll_course, drop_course from dao import query_courses, get_student_courses class CourseSystemApp: def __init__(self, root): self.root root self.root.title(选课信息管理系统) self.root.geometry(900x600) self.current_student None self.show_login() def show_login(self): 登录界面 self.clear_window() frame ttk.Frame(self.root, padding40) frame.pack(expandTrue) ttk.Label(frame, text选课信息管理系统, font(微软雅黑, 18)).grid( row0, column0, columnspan2, pady20) ttk.Label(frame, text学号).grid(row1, column0, stickye, pady5) self.id_entry ttk.Entry(frame, width25) self.id_entry.grid(row1, column1, pady5) ttk.Label(frame, text密码).grid(row2, column0, stickye, pady5) self.pwd_entry ttk.Entry(frame, width25, show*) self.pwd_entry.grid(row2, column1, pady5) ttk.Button(frame, text登录, commandself.do_login).grid( row3, column0, columnspan2, pady20) def do_login(self): sid self.id_entry.get().strip() pwd self.pwd_entry.get().strip() # 这里简化处理实际应该查数据库验证 from dao import get_connection conn get_connection() student conn.execute( SELECT * FROM students WHERE student_id ? AND password ?, (sid, pwd) ).fetchone() conn.close() if student: self.current_student dict(student) self.show_main() else: messagebox.showerror(错误, 学号或密码不正确) def show_main(self): 主界面课程列表 已选课程 self.clear_window() notebook ttk.Notebook(self.root) notebook.pack(fillboth, expandTrue, padx10, pady10) # 可选课程标签页 course_frame ttk.Frame(notebook) notebook.add(course_frame, text可选课程) self.build_course_table(course_frame) # 已选课程标签页 selected_frame ttk.Frame(notebook) notebook.add(selected_frame, text我的课表) self.build_selected_table(selected_frame) def build_course_table(self, parent): 构建课程列表表格 columns (课程号, 课程名, 教师, 学分, 容量, 已选, 时间) self.tree ttk.Treeview(parent, columnscolumns, showheadings) for col in columns: self.tree.heading(col, textcol) self.tree.column(col, width110) self.tree.pack(fillboth, expandTrue, sideleft) scrollbar ttk.Scrollbar(parent, orientvertical, commandself.tree.yview) scrollbar.pack(sideright, filly) self.tree.configure(yscrollcommandscrollbar.set) # 底部操作按钮 btn_frame ttk.Frame(parent) btn_frame.pack(sidebottom, fillx, pady5) ttk.Button(btn_frame, text选课, commandself.do_enroll).pack(sideleft, padx5) ttk.Button(btn_frame, text刷新, commandself.refresh_courses).pack(sideleft, padx5) self.refresh_courses() def refresh_courses(self): 从数据库重新加载课程列表 for item in self.tree.get_children(): self.tree.delete(item) courses query_courses() for c in courses: self.tree.insert(, end, values( c[course_id], c[course_name], c[teacher], c[credits], c[capacity], c[enrolled], c[schedule] )) def do_enroll(self): 选课按钮回调 selected self.tree.selection() if not selected: messagebox.showwarning(提示, 请先选择一门课程) return course_id self.tree.item(selected[0])[values][0] success, msg enroll_course(self.current_student[student_id], course_id) if success: messagebox.showinfo(结果, msg) self.refresh_courses() else: messagebox.showerror(选课失败, msg) def clear_window(self): for widget in self.root.winfo_children(): widget.destroy()这段代码里clear_window方法用winfo_children()遍历销毁所有子控件实现界面切换。这是 Tkinter 里最简单的多页面方案比Frame.tkraise()更直观但要注意销毁后原来的控件引用会失效别在切换后还去操作旧控件。ttk.Treeview的showheadings参数隐藏了默认的首列树形展开列只显示我们定义的列。不加这个参数表格左边会多出一列空白。3.2 课程列表的搜索与筛选课程数量多了以后学生需要搜索功能。在界面上加一个搜索框绑定回车事件或按钮点击调用query_courses(keyword...)即可。def build_search_bar(self, parent): 在课程列表上方加搜索栏 search_frame ttk.Frame(parent) search_frame.pack(sidetop, fillx, pady5) ttk.Label(search_frame, text搜索).pack(sideleft) self.search_entry ttk.Entry(search_frame, width30) self.search_entry.pack(sideleft, padx5) self.search_entry.bind(Return, lambda e: self.do_search()) ttk.Button(search_frame, text查找, commandself.do_search).pack(sideleft) def do_search(self): keyword self.search_entry.get().strip() for item in self.tree.get_children(): self.tree.delete(item) courses query_courses(keywordkeyword if keyword else None) for c in courses: self.tree.insert(, end, values( c[course_id], c[course_name], c[teacher], c[credits], c[capacity], c[enrolled], c[schedule] ))搜索用的是 SQL 的LIKE模糊匹配同时匹配课程名和教师名。数据量在几千条以内这种方式的响应速度完全够用。如果课程数超过万级需要考虑全文索引但课程设计场景下没必要。3.3 退课功能与已选列表刷新退课比选课简单但要注意把enrolled计数减回去同时把选课记录的状态改成dropped而不是直接删除——保留历史记录方便后续查证。def drop_course(student_id, course_id): 退课逻辑 conn get_connection() try: result conn.execute( UPDATE enrollments SET status dropped WHERE student_id ? AND course_id ? AND status active, (student_id, course_id) ) if result.rowcount 0: return False, 未找到有效的选课记录 conn.execute( UPDATE courses SET enrolled enrolled - 1 WHERE course_id ?, (course_id,) ) conn.commit() return True, 退课成功 except Exception as e: conn.rollback() return False, f退课失败{str(e)} finally: conn.close()result.rowcount判断是否有记录被更新如果学生根本没选这门课rowcount 为 0直接返回错误提示。这个检查避免了enrolled被错误地减一。4. 避坑与排查选课系统开发中最容易翻车的五个地方4.1 并发选课导致超卖现象是课程已选人数超过容量现象压测时发现某门容量 60 的课程enrolled字段变成了 62。两个学生同时选最后两个名额都通过了容量检查然后都执行了插入和更新。原因检查容量和更新计数之间存在时间窗口。SQLite 默认的隔离级别下两个连接可以同时读到enrolled58都认为还有名额。解决把检查和更新合并成一条原子 SQL利用WHERE enrolled capacity条件def enroll_atomic(student_id, course_id): conn get_connection() try: conn.execute(BEGIN IMMEDIATE) # 立即获取写锁 # 原子更新只有容量未满时才增加计数 result conn.execute( UPDATE courses SET enrolled enrolled 1 WHERE course_id ? AND enrolled capacity, (course_id,) ) if result.rowcount 0: conn.rollback() return False, 课程已满 conn.execute( INSERT INTO enrollments (student_id, course_id) VALUES (?, ?), (student_id, course_id) ) conn.commit() return True, 选课成功 except Exception as e: conn.rollback() return False, str(e) finally: conn.close()BEGIN IMMEDIATE让事务在开始时就获取写锁阻止其他连接同时修改。WHERE enrolled capacity确保只有容量未满时更新才会生效。这两层保护下超卖问题基本不会出现。4.2 时间冲突检测漏判现象是学生选上了同一时段的两门课现象学生选了周一3-4节的课又成功选了周一4-5节的课两门课有重叠节次但系统没拦住。原因parse_schedule函数只处理了3-4这种连续格式如果数据里出现3,4或3-4,6这种格式解析会出错或返回空列表导致冲突检测失效。解决在数据导入阶段统一格式并在parse_schedule里加异常处理def parse_schedule(schedule): 支持 周一3-4节、周一3,4节、周一3-4,6-7节 三种格式 if not schedule or len(schedule) 3: return , [] day schedule[:2] period_str schedule[2:].replace(节, ) periods [] for part in period_str.split(,): if - in part: start, end part.split(-) periods.extend(range(int(start), int(end) 1)) else: periods.append(int(part)) return day, periods4.3 SQLite 数据库文件被锁现象是程序运行中突然报 database is locked现象同时打开两个界面操作或者一个操作还没提交时另一个操作尝试写入报sqlite3.OperationalError: database is locked。原因SQLite 默认使用文件锁同一时间只允许一个写操作。如果有连接没有及时关闭或者事务长时间未提交其他写操作就会超时。解决设置timeout参数让连接等待锁释放同时确保每个函数在finally里关闭连接def get_connection(): conn sqlite3.connect(DB_PATH, timeout10) # 最多等 10 秒 conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) conn.execute(PRAGMA journal_mode WAL) # 开启 WAL 模式提升并发读性能 return connPRAGMA journal_mode WAL让读写可以并发进行写操作不再阻塞读操作。这是 SQLite 做小型应用时最值得开的一个参数。4.4 学分统计包含已退课程现象是退课后学分上限仍然被占用现象学生退了 3 学分的课但再选课时提示超出学分上限。原因学分统计的 SQL 没有过滤status active把dropped状态的记录也算进去了。解决所有涉及当前已选的查询都必须带status active条件。建议在 DAO 层封装一个get_active_credits(student_id)函数所有地方统一调用避免遗漏。4.5 Tkinter 界面卡死现象是点击选课后窗口无响应现象选课按钮点击后界面冻结几秒才恢复如果数据库操作慢窗口直接显示未响应。原因Tkinter 的事件循环在主线程数据库操作也在主线程执行耗时操作阻塞了界面刷新。解决把数据库操作放到独立线程里执行完成后通过root.after()回到主线程更新界面import threading def do_enroll_async(self): selected self.tree.selection() if not selected: messagebox.showwarning(提示, 请先选择一门课程) return course_id self.tree.item(selected[0])[values][0] def worker(): success, msg enroll_course(self.current_student[student_id], course_id) # 回到主线程更新界面 self.root.after(0, lambda: self.on_enroll_done(success, msg)) threading.Thread(targetworker, daemonTrue).start() def on_enroll_done(self, success, msg): if success: messagebox.showinfo(结果, msg) self.refresh_courses() else: messagebox.showerror(选课失败, msg)daemonTrue让线程随主程序退出而结束避免关窗口后线程还在跑。root.after(0, ...)把回调排入主线程的事件队列这是 Tkinter 里跨线程更新界面的标准做法。5. 从课程设计到能写进简历三个让项目加分的进阶技巧5.1 加一层简单的缓存减少数据库查询课程列表在短时间内不会变化每次刷新都查数据库没必要。用一个字典做内存缓存设置过期时间import time _cache {} CACHE_TTL 30 # 秒 def query_courses_cached(keywordNone): cache_key fcourses_{keyword} now time.time() if cache_key in _cache: data, expire_at _cache[cache_key] if now expire_at: return data result query_courses(keywordkeyword) _cache[cache_key] (result, now CACHE_TTL) return result选课或退课成功后调用_cache.clear()清空缓存。这个改动代码量不到 20 行但能让课程列表的刷新速度肉眼可见地变快答辩时演示效果很好。5.2 用 Flask 替换 Tkinter 做 Web 界面如果想让项目看起来更现代把界面层换成 Flask后端逻辑完全不用改。核心路由就三个路由方法功能/loginPOST验证学号和密码返回 token/coursesGET返回课程列表 JSON/enrollPOST执行选课返回结果from flask import Flask, request, jsonify from service import enroll_course from dao import query_courses app Flask(__name__) app.route(/courses) def list_courses(): keyword request.args.get(keyword, ) courses query_courses(keywordkeyword if keyword else None) return jsonify(courses) app.route(/enroll, methods[POST]) def do_enroll(): data request.get_json() success, msg enroll_course(data[student_id], data[course_id]) return jsonify({success: success, message: msg})前端用一个简单的 HTML 页面加fetch调用这些接口就行。Flask 自带开发服务器app.run(debugTrue)就能跑起来。这样项目从桌面程序变成了Web 应用简历上写起来也更有分量。5.3 用单元测试证明你的冲突检测逻辑是对的课程设计答辩时老师经常会问你怎么保证选课冲突检测没问题。与其口头解释不如直接跑测试import unittest from service import check_time_conflict, parse_schedule class TestConflict(unittest.TestCase): def test_same_day_overlap(self): self.assertTrue(check_time_conflict(周一3-4节, 周一4-5节)) def test_same_day_no_overlap(self): self.assertFalse(check_time_conflict(周一3-4节, 周一5-6节)) def test_different_day(self): self.assertFalse(check_time_conflict(周一3-4节, 周二3-4节)) def test_parse_comma_format(self): day, periods parse_schedule(周三1,2,3节) self.assertEqual(day, 周三) self.assertEqual(periods, [1, 2, 3]) if __name__ __main__: unittest.main()这几个测试用例覆盖了冲突检测的主要分支。跑一遍全绿答辩时直接展示测试结果比说我测过了有说服力得多。我自己做这类项目最大的教训是别一上来就追求功能多先把选课、退课、冲突检测这三个核心流程跑通数据一致性不出问题再去加花哨的功能。一个能稳定演示的系统比一个功能多但到处是 bug 的系统得分高得多。希望帮到你。本文还有配套的精品资源点击获取