简介:基于Python实现的医院信息管理系统项目,面向计算机专业学生、毕业设计开发者以及正在学习Python Web开发的人员。项目整合了HTML前端页面与Python后端逻辑,覆盖病人信息管理、预约挂号、病历查询、门诊住院等典型业务,并体现MVC分层、数据库接口、异常处理等实践要点。包内共93个文件,以52个HTML页面和31个Python脚本为主,另有SQL数据库脚本、配置文件、依赖清单、数据库迁移配置和项目说明,压缩包仅76KB,轻量而完整。系统按门诊、住院、家庭医生、收费等模块组织目录,便于直接阅读源码、搭建实验环境或在此基础扩展功能。目前已有2118人学习下载,通过该项目可掌握从数据建模、数据库迁移到界面交互的完整开发思路,适合作为课程设计、毕业设计或中小型医院信息管理系统的开发基础。
1. 医院信息管理系统:用 Python 快速搭出能闭环的桌面原型
「基于 Python 实现医院信息管理系统.zip」这类源码包,目标通常是把挂号、看诊、处方、收费、药房取药这条主流程,在一个桌面程序里完整跑通。Python 在这个场景里的优势是开发链路短:SQLite 省掉数据库安装,Tkinter 省掉前端工程化,单文件入口省掉部署配置,尤其适合万级数据量以下的小型机构数字化。适合三拨人:正在为课程设计或毕设选型的开发者、想验证自研方案的小型诊所技术负责人、以及第一次接触桌面数据库应用的 Python 使用者。拿到代码先别急着跑界面,花半小时理清三件事:登录权限怎么控、号源怎么生成、收费和库存是不是同一个事务。
2. 系统架构与数据建模:HIS 系统的地基该长什么样
2.1 模块边界划分:最小可用闭环的五大模块
一个能独立运转的小型医院信息管理系统,业务主链路是「挂号 → 看诊 → 处方 → 收费 → 药房取药」。源码里至少要能找出五块:登录与权限,区分管理员、医生、护士和药房人员;挂号管理,负责患者建档、科室选择和排队号生成;门诊看诊,让医生录入诊断和处方;收费结算,按处方计算金额并完成支付;药房管理,处理药品出入库和处方核销。这五块缺了任何一块,系统都只能叫演示 Demo,算不上管理信息系统。
模块之间要控制耦合,常见做法是把每个模块做成独立 Python 包,业务逻辑全部收敛到类方法中,界面层只调用方法而不直接碰 SQL。比如挂号模块对外暴露create_visit()和cancel_visit(),内部怎么查表、怎么更新号源,调用方不需要关心。这样做的直接好处,是后面把 Tkinter 换成别的界面框架,或者给系统增加一台自助挂号终端时,业务层完全不用动。
接口数量必须克制。我一般要求一个业务类只暴露 5 到 8 个方法,挂号类就做建档、挂号、取消、查队列,收费类就做计价、结算、退款、查账单。方法数量一旦失控,说明模块职责没拆干净。这种失控的代价在写代码的头两周看不出来,等开始补测试或者排查线上问题时,每改一处逻辑都要牵连十几个文件,这是典型的「拆分时偷懒,测试时还债」。
2.2 数据库选型:SQLite 与 MySQL 的分水岭在哪
数据层是这套系统最容易返工的地方。课程设计和单机场景里,源码大多直接用 SQLite,零配置、单文件、标准库自带驱动,开箱即用。但它本质是文件级数据库,同一时刻只允许一个写事务,写锁冲突时靠busy_timeout排队。数据量在几千条挂号、几千张处方时完全够用;一旦要部署到二三十台电脑的局域网里,多窗口连续交费、药房并发盘点,SQLite 的锁策略就会成为瓶颈,这时要尽早迁到 MySQL。
| 对比项 | SQLite | MySQL(PyMySQL) |
|---|---|---|
| 部署成本 | 零配置,单文件 | 需独立服务端 |
| 并发写能力 | 库级锁,适合 1–5 个写连接 | 行级锁,适合多终端 |
| 备份恢复 | 直接拷贝 .db 文件 | mysqldump 或 binlog |
| Python 接入 | sqlite3 内置 | pymysql + 连接池 |
| 适合场景 | 单机课设、单人诊所 | 局域网多科室、报表汇总 |
选型时还有两个容易忽视的细节。一是 MySQL 下要统一用 InnoDB,MyISAM 不带行级锁和事务,异常发生时不会回滚,不适合做收费这类资金链路。二是连接管理的思路不同:MySQL 建议用连接池复用连接,避免频繁握手;SQLite 相反,要短开短关、连上执行完立刻释放,长期持有一个写连接反而容易把自己锁住。
连接参数直接影响稳定性。SQLite 的初始化连接建议在程序入口就写好下面两行,能省去多数锁冲突困扰:
import sqlite3 conn = sqlite3.connect("hospital.db", timeout=10) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA busy_timeout=5000;")journal_mode=WAL把默认的 delete 日志改成预写日志,写入不再阻塞读取,挂号窗口在生成号源的同时,药房查询库存不会互相卡住;busy_timeout=5000指定锁等待毫秒数,不设置时并发写入会立刻抛database is locked,设置后会排队等待而不是闪退。这两个参数任何一个漏掉,都会在多窗口并发时把后台操作卡在数据库层。
2.3 核心表结构:六张表撑起挂号、处方、收费全链路
表结构设计直接决定查询好不好写。下面这份建表脚本是小型 HIS 最少需要的一组表,覆盖患者建档、医生档案、挂号、处方、处方明细、收费六条线。
CREATE TABLE patients ( patient_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, gender TEXT CHECK(gender IN ('男', '女')), birth_date TEXT, phone TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE doctors ( doctor_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, department TEXT NOT NULL, title TEXT ); CREATE TABLE visits ( visit_id INTEGER PRIMARY KEY AUTOINCREMENT, patient_id INTEGER NOT NULL, doctor_id INTEGER NOT NULL, visit_date TEXT NOT NULL, queue_no INTEGER NOT NULL, status TEXT DEFAULT 'pending', FOREIGN KEY (patient_id) REFERENCES patients(patient_id), FOREIGN KEY (doctor_id) REFERENCES doctors(doctor_id) ); CREATE TABLE prescriptions ( rx_id INTEGER PRIMARY KEY AUTOINCREMENT, visit_id INTEGER NOT NULL, doctor_id INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE rx_items ( item_id INTEGER PRIMARY KEY AUTOINCREMENT, rx_id INTEGER NOT NULL, drug_id INTEGER NOT NULL, quantity INTEGER NOT NULL, unit_price REAL NOT NULL, amount REAL NOT NULL ); CREATE TABLE charges ( charge_id INTEGER PRIMARY KEY AUTOINCREMENT, visit_id INTEGER NOT NULL, total_amount REAL NOT NULL, paid_at TEXT DEFAULT (datetime('now', 'localtime')), staff_id INTEGER NOT NULL );几个字段值得专门解释。rx_items.unit_price保存的是开处方当时的单价快照,这是收费系统最常见的隐藏需求——药品调价后,历史处方仍然按旧价格结算,如果不冗余单价字段,届时只能回头去查药品表,历史金额全部算错。visits.status用字符串而不是布尔,因为状态至少有三态:pending待就诊、finished已完成、cancelled已取消。所有日期统一存成YYYY-MM-DD HH:MM:SS文本,后续做按日统计直接排序比较,不需要时间戳转换。
这段脚本里还少了两张关键表:用户表和号源计数表。用户表负责登录与角色,号源计数表负责并发下的排队号生成,第 3 章写业务逻辑会一并给出。建议建表用独立脚本一次性初始化,不要在每个模块里各写一句CREATE TABLE IF NOT EXISTS,后者会让真实表结构散落在代码各处,排查问题时很难一眼看清字段到底怎么定义的。
3. 用代码串起业务:登录、挂号到收费的事务链路
3.1 登录与权限:用装饰器做角色控制,而不是每行业务都加 if
登录是系统入口,也是最容易写成摆设的地方。很多示例把用户表做成明文密码,角色判断散落在每个窗口的按钮事件里,改一个角色的权限要动十几个文件。常见做法是用一个Session类保存当前登录状态,配合装饰器做权限拦截,把权限判断收敛到函数入口。
import functools import sqlite3 class AuthError(Exception): pass class Session: _user = None _role = None @classmethod def login(cls, username, password): conn = sqlite3.connect("hospital.db") cur = conn.execute( "SELECT user_id, role FROM users WHERE username=? AND password=?", (username, password) ) row = cur.fetchone() conn.close() if not row: raise AuthError("用户名或密码错误") cls._user, cls._role = row return True @classmethod def logout(cls): cls._user = cls._role = None def require_role(*allowed_roles): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): if Session._role not in allowed_roles: raise AuthError(f"角色 {Session._role} 无权执行该操作") return func(*args, **kwargs) return wrapper return decoratorlogin里的查询用了参数绑定(username, password),而不是 f-string 拼 SQL,这是防止注入的基本底线。require_role是装饰器工厂,调用时传入允许的角色列表,比如收费操作上标@require_role("admin", "cashier")。真实项目里密码不能明文存库,至少要加盐后保存散列值,这里为了演示简洁才直接比对原文。
这种设计的直接收益是权限逻辑集中了。新增一个「只读报表」角色时,不需要进每个窗口找按钮,只要给对应业务方法标注允许角色即可。界面上的按钮显隐仍然要做,但那只是交互层的体验优化,真正的控制必须在业务方法入口,否则命令行或脚本依然能绕过界面直接调方法。
3.2 挂号模块:排队号生成要处理并发,不能用 max+1
挂号是系统里并发压力最大的环节。最容易翻车的写法是SELECT MAX(queue_no) + 1,两个线程同时运行时读到同一个最大值,生成重复号。正确做法是单独维护一张号源计数表,利用数据库的原子更新替代代码里的「读-改-写」。
def create_visit(patient_id, doctor_id): conn = sqlite3.connect("hospital.db", timeout=10) try: today = datetime.now().strftime("%Y-%m-%d") conn.execute("BEGIN IMMEDIATE;") cur = conn.execute( """UPDATE visit_counter SET current_no = current_no + 1 WHERE visit_date = ? AND doctor_id = ?""", (today, doctor_id) ) if cur.rowcount == 0: conn.execute( "INSERT INTO visit_counter(visit_date, doctor_id, current_no) VALUES (?, ?, 1)", (today, doctor_id) ) queue_no = 1 else: queue_no = conn.execute( "SELECT current_no FROM visit_counter WHERE visit_date=? AND doctor_id=?", (today, doctor_id) ).fetchone()[0] conn.execute( "INSERT INTO visits(patient_id, doctor_id, visit_date, queue_no) VALUES (?,?,?,?)", (patient_id, doctor_id, today, queue_no) ) conn.commit() return queue_no except sqlite3.OperationalError: conn.rollback() raise RuntimeError("号源更新冲突,请重试") finally: conn.close()BEGIN IMMEDIATE在写操作前就获取写锁,避免默认模式下锁升级引起的死锁。UPDATE的rowcount用来判断当天该医生是否已有计数行:没有就插入初始值 1,有就取更新后的值。由于UPDATE本身是原子操作,并发时两个请求不可能同时成功,后到的那个会等待行锁或者在锁等待超时后进入异常分支。
这里的表visit_counter(visit_date, doctor_id, current_no)需要在业务初始化时建好,并且给(visit_date, doctor_id)建唯一索引。索引是为了防止重复插入计数行,同时也是这个方案能安全运行的前提条件。获取号源和插入挂号记录在同一个事务里,即使插入失败,计数也会回滚,不会出现号被占但记录不存在的情况。
3.3 收费与处方核销:必须把扣库存和记账放进同一个事务
收费是最不能出错的一环。只减库存不记账,或者记了账没把挂号状态改掉,都会造成账实不符。标准做法是把计价、扣库存、更新就诊状态放进同一个数据库事务,任一步失败都整体回滚。
def settle_charge(rx_id, cashier_id): conn = sqlite3.connect("hospital.db", timeout=10) try: conn.execute("BEGIN IMMEDIATE;") total = conn.execute( "SELECT SUM(quantity * unit_price) FROM rx_items WHERE rx_id = ?", (rx_id,) ).fetchone()[0] if not total: raise ValueError("处方为空,无法收费") conn.execute( "INSERT INTO charges(visit_id, total_amount, staff_id) VALUES (?,?,?)", (rx_id, total, cashier_id) ) conn.execute( """UPDATE drugs SET stock = stock - ( SELECT quantity FROM rx_items WHERE drug_id = drugs.drug_id AND rx_id = ? ) WHERE drug_id IN (SELECT drug_id FROM rx_items WHERE rx_id = ?)""", (rx_id, rx_id) ) conn.execute( """UPDATE visits SET status = 'finished' WHERE visit_id = ( SELECT visit_id FROM prescriptions WHERE rx_id = ? )""", (rx_id,) ) conn.commit() return total except Exception: conn.rollback() raise finally: conn.close()这里需要重点说明的是为什么收费记录关联到rx_id(处方号)而不是visit_id(就诊号)。一次就诊可以开多张处方,分开收费是常态,按处方粒度记账才能在退款和报表统计时对上账。扣库存那条 SQL 用了子查询匹配,一次性更新所有涉及药品,没有在 Python 里循环执行多条UPDATE,避免了一个事务里产生多次网络往返。
MySQL 下迁移这套代码只需要两处改动:事务控制从BEGIN IMMEDIATE换成START TRANSACTION,驱动换成 PyMySQL。另外SELECT SUM(...)在 MySQL 里要用IFNULL包裹,因为空结果返回None而不是 0。其余 SQL 基本可以直接平移,这也是当初把业务逻辑集中到类方法里的收益。
4. 界面层落地:用 Tkinter 把业务逻辑接到屏幕上
4.1 主界面框架与页面跳转:容器帧 + 销毁重建
桌面管理系统的界面逃不开这些窗口:登录、主控台、挂号登记、收费结算、药品管理。Tkinter 里不要为每个页面新建Tk()窗口,正确做法是单主窗口加内容容器,页面切换时销毁旧页面再重建新页面,避免多窗口间的数据同步问题。
class MainApp(tk.Tk): def __init__(self): super().__init__() self.title("医院信息管理系统") self.geometry("1024x640") self.page_container = tk.Frame(self) self.page_container.pack(fill="both", expand=True) self.show_page("login") def show_page(self, page_name): for child in self.page_container.winfo_children(): child.destroy() page_cls = { "login": LoginView, "dashboard": DashboardView, "register": RegisterView, "charge": ChargeView, }[page_name] page_cls(self.page_container, self).pack(fill="both", expand=True)这段代码的核心是winfo_children()配合destroy():切换页面时先把容器内所有子控件清空,再实例化新页面。比pack_forget()隐藏旧页面的方式更干净,不会出现页面切换多了之后控件叠在一起的问题。页面类统一接收父容器和主应用实例两个参数,子页面里跳转时直接调self.app.show_page("dashboard")。
页面类的写法规矩也要统一。每个页面类接收(parent, app),在构造方法里完成控件初始化和数据加载;需要刷新数据的页面,在show_page之后主动调用一次load_data()。不要在每个页面里都写一套自己的数据加载逻辑,更不要在页面构造时直接阻塞主线程做数据库查询,否则界面启动时会出现明显卡顿。
4.2 表格展示与挂号效率:Treeview 与滚动条的联动
挂号台的体验好坏,一半在表格。Tkinter 没有原生表格控件,最实用的是ttk.Treeview,配滚动条和双击事件完成查号、选人、登记。下面这段挂号队列的表格代码里,有几个参数值得按实际屏幕分辨率调整。
columns = ("queue", "name", "gender", "department", "status") self.tree = ttk.Treeview(parent, columns=columns, show="headings", height=15) self.tree.heading("queue", text="排队号") self.tree.heading("name", text="患者") self.tree.heading("gender", text="性别") self.tree.heading("department", text="科室") self.tree.heading("status", text="状态") self.tree.column("queue", width=80, anchor="center") self.tree.column("status", width=100, anchor="center") self.tree.column("department", width=150) self.tree.column("name", width=120) scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview) self.tree.configure(yscrollcommand=scrollbar.set)height=15让表格固定显示 15 行而非无限增高,记录多了窗口也不会失控。show="headings"去掉第一列的树形层级图标。滚动条必须双向绑定:滚动条调tree.yview,表格通过yscrollcommand通知滚动条更新位置,少一行表格就拖不动。挂号记录刷新时,先清空tree.get_children(),再按队列顺序插入,插入时用iid存visit_id,后面双击定位患者时直接取到主键。
4.3 表单联动校验:用 trace_add 把录入错误挡在提交之前
挂号表单常见的问题是「提交以后数据库报错」。与其事后弹窗,不如在录入阶段就拦截。Tkinter 里写联动校验最顺手的方式是给StringVar挂trace_add回调,值一变就执行检查逻辑。
self.id_card_var = tk.StringVar() def on_id_card_changed(*_): value = self.id_card_var.get().strip() if len(value) == 18 and value.isdigit(): year = int(value[6:10]) month = int(value[10:12]) day = int(value[12:14]) self.birth_var.set(f"{year}-{month:02d}-{day:02d}") self.id_card_var.trace_add("write", on_id_card_changed)这段实现的是身份证号录满 18 位自动带出出生日期,护士只需要补姓名和电话,录入速度提升明显。注意trace_add回调会收到三个参数,用*_吞掉;回调里不要再对同一个StringVar做赋值操作,否则会触发二次回调造成死循环。如果确实要赋值,用trace_veto或者加一个防重入标志,这是新手最容易忽略的细节。
除了证件号联动,还有一类校验适合放在「提交按钮」而不是输入过程中:药品数量的非零检查、收费金额与处方金额的一致性比对。前者追求即时反馈,后者必须在数据完整时做终审,两者职责不同,混在一起会让界面交互变得非常啰嗦。
4.4 什么时候换 PyQt5:三个明确的升级信号
Tkinter 在课程设计和单机场景够用,但出现三个信号时建议换 PyQt5 或 PySide:一是布局复杂度上来,多列表单用grid和place写了大量冗余配置;二是需要真正的模态对话框和子窗口;三是表格需要单元格编辑、排序、筛选等交互,Treeview支持不了。换框架的代价要提前算清,PyQt5 打包体积会比 Tkinter 大几十 MB,还涉及 GPL 许可问题,想避开可以选 PySide6。我的原则是业务数据链路测稳之前不轻易换界面层,界面永远是替换成本最低的一层。
5. HIS 项目避坑指南:5 个最常见的翻车现场与解决办法
5.1 现象:database is locked,程序卡死
多窗口同时操作时,比如挂号窗口提交时药房正在核销,一个窗口弹出sqlite3.OperationalError: database is locked,严重时界面无响应十几秒。
原因:SQLite 写操作会锁整个库文件,两个写事务撞出冲突,其中一方等待超过busy_timeout就抛异常。最常见的是建连时没设超时参数,或者连接长期持有不释放,把写锁一直占着。
解决:每个数据库操作短开短关,用try/finally或with确保连接关闭;连接参数加上timeout=10和busy_timeout=5000;写操作统一走BEGIN IMMEDIATE。多线程场景下,每个线程独立创建连接,绝不在线程之间共享同一个Connection对象。
5.2 现象:写入数据库的中文变成问号
Windows 下运行界面中文正常,入库后显示??,或者导出 CSV 后用 Excel 打开全是乱码。
原因:Python 3 的字符串与 SQLite 交互默认 UTF-8,问题多半出在文件读写层面。open("patients.csv", "w")没指定编码时,Windows 默认 GBK,换到其他机器上就跑崩;某些 IDE 保存源码文件时用的是 GBK,字节流写入后自然乱码。
解决:所有文件操作显式传encoding="utf-8";CSV 导出用utf-8-sig,Excel 才能直接识别;代码文件统一按 UTF-8 保存,并在文件头部声明# -*- coding: utf-8 -*-。虽然 Python 3 默认以 UTF-8 解析源码,但保留这行声明可以规避部分编辑器换编码造成的意外。
5.3 现象:并发挂号出现重复排队号
两个护士同时在各自窗口挂号,数据库里出现同日期、同医生、同queue_no的两条记录。
原因:代码用了「先查最大号再加一」的写法,两个并发事务读到同一个最大值。本质是 read-modify-write 竞态条件,Python 层的threading.Lock只能锁自家进程,锁不住另一个进程的窗口操作。
解决:把号源计数交给数据库。第 3 章已经给出visit_counter加UPDATE ... SET current_no = current_no + 1的写法,利用数据库行锁保证原子性。这里要特别注意,业务代码里不要再出现任何形式的SELECT MAX(queue_no)逻辑,否则改了一处漏了另一处,重复号依然会出现。
5.4 现象:按日期查询挂号记录,下午开始就查不全
查询当天挂号列表,早上数据正常,下午之后数量对不上,部分记录查不出来。
原因:日期字段里存了完整时间戳2024-06-18 14:30:00,查询条件写的却是WHERE visit_date = '2024-06-18',等值比较永远匹配不到带时间的记录。反之,如果查询条件写了时间而数据没写时间,结果也会漏。
解决:统一约定字段语义。要么把visit_date只存日期,另建visit_time存时间;要么查询时用WHERE date(visit_date) = ?包裹函数。后者在数据量上去后会放弃索引全表扫描,所以更推荐前者。表结构里把日期和时间拆开,代价是插入时多写一个字段,收益是统计查询永远不用加date()函数。
5.5 现象:PyInstaller 打包后双击即闪退
源码运行时一切正常,打包成单个 exe 换一台电脑双击没反应或秒退,命令行运行提示缺少模块。
原因:PyInstaller 不会自动识别动态导入的模块,尤其是字符串映射页面类这种写法,打包后依赖缺失。tkinter和sqlite3属于较容易被漏掉的部分,换机器后问题更明显。
解决:打包命令显式补--hidden-import=tkinter --hidden-import=sqlite3;提供明确的入口脚本而不是打包整个目录;数据库文件的定位用os.path.abspath()相对 exe 目录,而不是相对当前工作目录。打包后先在一台没装 Python 的干净机器上测一遍,这一步能拦下九成交付事故。
6. 验证与升级:用「三压一查」判断系统能不能真实使用
一个能跑通演示的 HIS 系统,和能真实站稳的系统,差距通常体现在三个压力点上。我拿到一套项目代码后会做一套「三压一查」验收,顺序不能乱。
第一步压业务量。造 5000 条患者、20000 条处方明细,模拟一周高峰流量,逐个跑挂号、查询、收费页面,观察响应时间。查询超过 1 秒的,优先检查索引:
CREATE INDEX idx_visit_date ON visits(visit_date); CREATE INDEX idx_rx_visit ON prescriptions(visit_id); CREATE INDEX idx_charges_visit ON charges(visit_id);第二步压并发。开十个线程同时挂号,确认号源没有重复、没有锁死;再开五个线程同时收费,确认库存扣减与收费记录一致。第三步做断电恢复。在收费事务执行到一半时强制结束进程,重启后核对库存和收费记录,结论必须是一致,否则事务边界还有漏洞。
第四查,查权限漏洞。用低权限账号直接调用收费、退号、改价这类业务方法,确认装饰器真的拦住了。很多系统把权限隐藏到界面层,按钮看不见,但命令行一样能执行收费函数,这类漏洞在真实部署中会直接变成事故。
升级方向上,先把报表统计拎出来做,这是机构最看重的功能。按科室汇总每日挂号量、按药品汇总消耗量、按月份汇总收入,三张报表用GROUP BY加日期函数就能完成,前提是字段类型和日期格式从一开始没搞乱。经历过一次上线后账目对不上的折腾,我现在写每个写事务都默认加事务包裹,每张业务表至少留一个创建时间字段。这不是什么高深技巧,就是踩坑踩出来的习惯。希望帮到你。
本文还有配套的精品资源,点击获取