
简介基于Python和PyQt5的图书信息管理系统是一份面向图书馆员、教师及个人藏书爱好者的完整项目包也适合正在学习Python GUI编程与数据库开发的读者使用。系统涵盖图书录入、检索、编辑、删除、数据导入导出与报表统计等常见功能后端采用Python与SQLite数据库前端通过PyQt5实现交互界面结构清晰、开箱即用。压缩包共91个文件包含35个py源码、14个ui界面文件、2个sql数据库脚本另有说明文档doc、md及txt说明整体仅2.51MB便于快速下载与部署目录中按view、ui、util等模块划分方便二次开发。目前已有79人学习下载适合作为课程设计、毕业设计或自学练手的参考资料借助附带的说明文档可快速掌握系统配置、运行流程与扩展思路。1. 拿到这个课程设计项目后先想清楚三步再动手“基于Python和PyQt5的图书信息管理系统”这类项目包大学里几乎每个学期都会出现。zip 里面通常是一个 PyQt5 写的桌面程序、一个 SQLite 数据库文件、一份说明文档。它能直接演示图书的增删改查、借还书和登录适合交课程设计也适合拿来改造成团队内部的资料台账工具。但解压后直接双击 main.py 大概率跑不起来——依赖没装、数据库路径不对、信号槽没绑定任何一个环节都能让程序静默崩溃。所以拿到压缩包的第一件事不是兴奋地点开代码而是先确认三件事数据库文件是否有效、依赖版本是否兼容、说明文档里写没写启动命令。下面按“工程目录 → 数据库 → 界面 → 排错 → 打包”的顺序把这个系统彻底拆开。2. 先别急着双击 main.py工程目录与数据流设计2.1 一个标准 zip 里应该有什么目录结构决定后续维护成本解压后常见的目录结构长这样图书信息管理系统/ ├── main.py # 程序入口创建 QApplication ├── requirements.txt # 依赖清单经常缺版本号 ├── db/ │ ├── book_manager.db # SQLite 数据库文件 │ ├── book_dao.py # 数据访问层 │ └── init_db.sql # 建表脚本 ├── ui/ │ ├── login_window.py # 登录窗口 │ ├── main_window.py # 主窗口 │ ├── add_book_dialog.py # 新增/编辑弹窗 │ └── borrow_dialog.py # 借还书弹窗 ├── docs/ │ └── 使用说明.md # 运行环境/操作步骤/功能说明 └── resources/ # 图标/样式表拿到 zip 后我先看两个文件requirements.txt和docs/使用说明.md。说明文档里通常会写 Python 版本、PyQt5 版本、数据库类型和启动命令如果文档写得好能省下至少半小时。如果项目里没带说明文档或者文档是套话模板就直接开始手动排查环境。依赖安装是最容易翻车的一关。常见的坑是requirements.txt只写了PyQt5没锁版本Python 3.12 上 PyQt5 的 wheel 有兼容性变化5.15.10 之前的版本装上后会出现PyQt5.sip导入错误。我一般会在这个项目目录里建一个虚拟环境把所有依赖装进去避免污染全局的 Python 环境python -m venv venv .\venv\Scripts\activate # Windows source venv/bin/activate # Linux/macOS pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyqt55.15.11 pyqt5-sip12.13.0这段命令里做了三件事第一用 venv 隔离项目依赖第二指定5.15.11和12.13.0这是社区里用得很顺手的一组版本组合第三换用国内镜像源避免下载 wheel 时因为网络波动出现哈希校验失败。如果你平时用 PyCharm也可以直接在 Settings - Project - Python Interpreter 里添加虚拟环境但命令行操作更透明出问题时日志更好定位。接下来还要确认数据库文件本身是否有效。用 SQLite 自带命令行工具看一下sqlite3 db/book_manager.db .tables正常输出会包含books、readers、borrows等表名。如果提示file is not a database说明路径不对或者文件已经被改坏如果输出为空说明这是一个空文件后面需要用初始化脚本重建表结构。这个动作是非常必要的验证手段因为很多 zip 传到网盘再下载后文件头已经被破坏程序跑起来只会得到“找不到表”的报错而你根本不知道是代码的问题还是文件的问题。2.2 数据流与模块划分谁说课程设计不需要分层很多课程设计把所有 SQL 直接写在按钮回调里看起来能跑但答辩时被问一句“如果换成 MySQL你的代码改多少”就会卡住。这个项目如果结构合理一定会包含一层 DAOData Access Object也就是把数据库操作封装到独立模块里。每个按钮只是呼叫 DAO 的接口而不是直接操作 SQLite。数据流是这样的用户点击“查询”按钮 → 按钮发出clicked信号 → 槽函数on_search_clicked调用BookDao.query_by_keyword→ DAO 拼 SQL、绑定参数、执行 fetch → 返回列表 → 槽函数把列表写进QTableWidget→ 表格刷新。代码层面的表现就是下面这段主窗口初始化from PyQt5.QtWidgets import QMainWindow, QTableWidget, QTableWidgetItem class MainWindow(QMainWindow): def __init__(self): super().__init__() self.dao None # 在 setup_dao 里初始化便于测试时替换 self.setup_dao() self.refresh_table() def setup_dao(self): # 常见做法是直接在这里创建 DAO 实例传入数据库路径 from db.book_dao import BookDao self.dao BookDao(db/book_manager.db) def refresh_table(self): rows self.dao.query_all() self.table.setRowCount(len(rows)) for i, row in enumerate(rows): for j, value in enumerate(row): self.table.setItem(i, j, QTableWidgetItem(str(value)))注意self.dao None再延迟赋值是为了在写单元测试时用一个 fake DAO 替换。QTableWidgetItem(str(value))这一步不可省略因为表格单元格只接受字符串value可能是 int 或None没有转 string 的话整列会显示空白。query_all返回的每一行是元组索引顺序要和数据库表字段保持一致界面层不要自己去猜。如果你看到的项目里没有db/目录而是直接在主窗口里sqlite3.connect也不是不能跑但我的经验是把 DAO 抽出来之后后续换数据库、加缓存、加日志都会轻松很多。这也是“pyqt5界面设计”里容易被忽略的一点——界面写再好数据层一团糟最后很难扩展。3. 数据库文件是核心建表、连接与增删改查3.1 从建表语句到数据库文件这几张表决定了系统能做什么图书信息管理系统涉及的数据至少有图书表、读者表、借阅记录表。zip 里如果有db/book_manager.db那就是已经初始化好的 SQLite 文件如果没有就需要用init_db.sql自己建。一张常见的 books 表设计如下字段类型约束说明idINTEGERPRIMARY KEY AUTOINCREMENT图书内部编号titleVARCHAR(100)NOT NULL书名authorVARCHAR(50)NOT NULL作者isbnVARCHAR(20)UNIQUEISBN 编号categoryVARCHAR(30)分类publisherVARCHAR(50)出版社publish_yearINTEGER出版年份total_countINTEGERDEFAULT 1馆藏总量available_countINTEGERDEFAULT 1可借数量created_atTIMESTAMPDEFAULT CURRENT_TIMESTAMP入库时间这里最容易被忽略的是available_count。很多人只存总量借书时直接在total_count上减归还时再加最后总量越来越小。正确姿势是总量不变可借数增减。建表脚本一般长这样CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(100) NOT NULL, author VARCHAR(50) NOT NULL, isbn VARCHAR(20) UNIQUE, category VARCHAR(30), publisher VARCHAR(50), publish_year INTEGER, total_count INTEGER DEFAULT 1, available_count INTEGER DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS readers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(30) NOT NULL, phone VARCHAR(20), card_no VARCHAR(20) UNIQUE ); CREATE TABLE IF NOT EXISTS borrows ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, reader_id INTEGER NOT NULL, borrow_date TEXT DEFAULT (datetime(now, localtime)), return_date TEXT, status TINYINT DEFAULT 0, FOREIGN KEY (book_id) REFERENCES books(id), FOREIGN KEY (reader_id) REFERENCES readers(id) );status字段习惯用 0 表示未还、1 表示已还这样查询“在借图书”就是WHERE status 0。datetime(now,localtime)是 SQLite 里取本地时间的方式很多教材写CURRENT_TIMESTAMP那是 UTC 时间会比你本地晚 8 个小时这个细节在答辩时主动讲出来很加分。接下来用 Python 初始化数据库。这里注意数据库连接路径不要写死相对路径要由__file__推导import sqlite3 import os DB_PATH os.path.join(os.path.dirname(os.path.abspath(__file__)), db, book_manager.db) def init_db(): conn sqlite3.connect(DB_PATH) conn.executescript( PRAGMA foreign_keys ON; CREATE TABLE IF NOT EXISTS books (...); CREATE TABLE IF NOT EXISTS readers (...); CREATE TABLE IF NOT EXISTS borrows (...); ) conn.execute( INSERT INTO books(title, author, isbn, total_count, available_count) VALUES (深入理解计算机系统, Randal E.Bryant, 9787111544937, 3, 3) ) conn.commit() conn.close()executescript可以一次执行多条语句但要注意PRAGMA foreign_keys ON必须在每条连接上单独开启SQLite 默认不启用外键约束。INSERT种子数据时如果没有去重重复执行会插入多条记录所以生产代码里要加INSERT OR IGNORE配合唯一索引。对课程设计来说插入几本常用书作为初始数据是必要的不然老师打开程序看到空表格第一印象就不好。3.2 数据库增删改查的正确姿势参数化与事务管理系统最核心的其实就是“数据库增删改查”。DAO 层代码我会这么写import sqlite3 class BookDao: def __init__(self, db_path): self.db_path db_path self.conn sqlite3.connect(db_path) self.conn.row_factory sqlite3.Row # 让行像字典一样访问 def query_all(self): sql SELECT * FROM books ORDER BY id return self.conn.execute(sql).fetchall() def query_by_keyword(self, kw): like f%{kw}% sql SELECT * FROM books WHERE title LIKE ? OR author LIKE ? OR isbn LIKE ? ORDER BY id return self.conn.execute(sql, (like, like, like)).fetchall() def insert_book(self, book: dict): sql INSERT INTO books(title, author, isbn, category, publisher, publish_year, total_count, available_count) VALUES(?, ?, ?, ?, ?, ?, ?, ?) cur self.conn.execute(sql, (book[title], book[author], book[isbn], book[category], book[publisher], book[publish_year], book[total_count], book[total_count])) self.conn.commit() return cur.lastrowid def delete_book(self, book_id): self.conn.execute(DELETE FROM books WHERE id?, (book_id,)) self.conn.commit()写这段时要注意几个参数细节conn.row_factory sqlite3.Row设置后取出来的行可以通过row[title]访问可读性比元组好很多。插入时available_count初始等于total_count不要在 SQL 里写死默认值。所有写操作后都必须调用conn.commit()很多人只在主程序结束时 commit程序中途崩掉数据就丢了。所有 SQL 都该用?绑定参数而不是用 f-string 拼接否则书名里带引号直接就执行出错更别提 SQL 注入风险。再给一个借书的完整事务例子。借书时要同时插入借阅记录、扣减可借数量还要检查是否还有库存def borrow_book(self, book_id, reader_id): row self.conn.execute( SELECT available_count FROM books WHERE id?, (book_id,) ).fetchone() if row is None or row[available_count] 0: return False try: self.conn.execute( INSERT INTO borrows(book_id, reader_id) VALUES (?, ?), (book_id, reader_id) ) self.conn.execute( UPDATE books SET available_count available_count - 1 WHERE id?, (book_id,) ) self.conn.commit() return True except Exception: self.conn.rollback() return False先查可借数量再写两条 SQL。如果不放在事务里第二步UPDATE失败时借阅记录就会成为孤儿数据。except之后的rollback()是后悔药把两条语句一起退回。SQLite 对并发写支持较弱这个系统是单机使用所以没问题如果以后想部署成网络版就要换成 MySQL 并考虑连接池。4. PyQt5 界面设计登录、主表格与弹窗的业务逻辑4.1 登录窗口文本框校验与会话保持登录窗口通常是一个继承QDialog的窗体放两个QLineEdit和一个“登录”按钮。点击按钮后的处理逻辑是重点from PyQt5.QtWidgets import QDialog, QMessageBox, QLineEdit, QPushButton class LoginWindow(QDialog): def __init__(self): super().__init__() self.username_le QLineEdit(self) self.password_le QLineEdit(self) self.password_le.setEchoMode(QLineEdit.Password) self.login_btn QPushButton(登录, self) self.login_btn.clicked.connect(self.handle_login) self.user_dao UserDao(db/book_manager.db) def handle_login(self): username self.username_le.text().strip() password self.password_le.text().strip() if not username or not password: QMessageBox.warning(self, 提示, 账号和密码不能为空) return user self.user_dao.verify(username, password) if user: self.accept() else: QMessageBox.critical(self, 错误, 用户名或密码错误)setEchoMode(QLineEdit.Password)让密码显示为圆点.strip()把首尾空格过滤掉避免用户不小心多按一个空格就提示账号不存在。self.accept()表示登录成功主程序拿到Accepted之后打开主窗口。UserDao.verify内部应该这样写def verify(self, username, password): row self.conn.execute( SELECT * FROM users WHERE username? AND password?, (username, password) ).fetchone() return row这里有一个安全点很多课程设计直接用明文密码答辩时你主动说“密码字段应该存 SHA-256 摘要”会很加分。做法很简单import hashlib password_hash hashlib.sha256(password.encode(utf-8)).hexdigest()如果原始数据库里已经是明文那你可以保持现状但知识点要能说清楚。登录成功之后把当前用户的信息挂到主窗口属性上比如self.main_window.current_user user这样在后来的借书、还书记录里可以标记操作人。4.2 主窗口表格刷新QTableWidget 与排序的坑主窗口最核心的控件是QTableWidget而不是QTableView QSqlTableModel。为什么课程设计要求可读性强QTableWidget直接用setItem填值代码简单也方便在最后一列塞按钮。刷新表格的正确方式from PyQt5.QtWidgets import QTableWidgetItem from PyQt5.QtCore import Qt def refresh_table(self): rows self.dao.query_all() self.tableWidget.setRowCount(len(rows)) self.tableWidget.setColumnCount(7) self.tableWidget.setHorizontalHeaderLabels( [编号, 书名, 作者, ISBN, 分类, 总量, 可借] ) self.tableWidget.clearContents() for i, row in enumerate(rows): values [row[id], row[title], row[author], row[isbn], row[category], row[total_count], row[available_count]] for j, value in enumerate(values): item QTableWidgetItem(str(value)) item.setTextAlignment(Qt.AlignCenter) self.tableWidget.setItem(i, j, item) self.tableWidget.setSortingEnabled(False)clearContents()会清掉单元格内容但保留表头。setSortingEnabled(False)很关键如果为 True每次setItem后表格会自动重新排序你填到第 3 行时它可能自己跳到别的位置最终数据错乱。需要排序时应该先填完所有数据再setSortingEnabled(True)。行尾的“编辑”“借书”按钮常见做法是把按钮塞进单元格btn QPushButton(编辑, self) btn.clicked.connect(lambda _, pidrow[id]: self.open_edit_dialog(pid)) self.tableWidget.setCellWidget(i, 7, btn)lambda里的pidrow[id]是把循环当前的值固化成默认参数。如果直接写lambda: self.open_edit_dialog(row[id])当循环结束后row是最后一行所有按钮都会编辑最后一本书这个坑非常隐蔽。这是典型的 PyQt5 信号槽闭包问题也是我在实际代码里反复出现的翻车点。4.3 新增/编辑对话框模态弹窗与表单校验新增和编辑共用一个QDialog是常见做法。对话框里放QLineEdit确定按钮返回表单数据from PyQt5.QtWidgets import QDialog, QMessageBox, QFormLayout, QLineEdit, QDialogButtonBox class BookEditDialog(QDialog): def __init__(self, bookNone, parentNone): super().__init__(parent) self.book book self.title_le QLineEdit(self) # 其他字段控件略 self.button_box QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.button_box.accepted.connect(self.validate_and_accept) self.button_box.rejected.connect(self.reject) def validate_and_accept(self): if not self.title_le.text().strip(): QMessageBox.warning(self, 提示, 书名不能为空) return self.accept() def get_data(self): return { title: self.title_le.text().strip(), author: self.author_le.text().strip(), isbn: self.isbn_le.text().strip(), category: self.category_le.text().strip(), publisher: self.publisher_le.text().strip(), publish_year: int(self.year_le.text() or 0), total_count: int(self.count_le.text() or 1), }主窗口添加按钮回调def on_add_book(self): dialog BookEditDialog(self) if dialog.exec_() QDialog.Accepted: data dialog.get_data() new_id self.dao.insert_book(data) self.refresh_table() QMessageBox.information(self, 成功, f添加成功编号 {new_id})exec_()是老 APIPyQt5 里和exec()都可用但既然项目基于 PyQt5我习惯用带下划线的版本保证代码在老的 PyQt5 教程里也能找到参考。QDialogButtonBox把确定/取消封装起来连接accepted比分别连接两个按钮干净。注意int(self.year_le.text() or 0)这种写法是为了在输入为空时不抛ValueError直接把年份当 0 处理。5. 运行常见问题与排错安装、路径、编码与控件上的 5 个坎5.1 PyQt5 安装失败并伴随 hash 校验错误现象pip install pyqt5执行到下载pyqt5-5.15.11-cp39-cp39-win_amd64.whl时报HASH does not match或直接卡在 resolving。原因PyPI 官方 CDN 在中国大陆不稳定下载的 wheel 可能损坏另外 Python 3.12 上 PyQt5 的兼容 wheel 同步较慢某些镜像源还没有覆盖。很多人在装 labelme 时也会碰到同样的 PyQt5 装不上问题本质都是源的问题。解决换国内镜像并锁定版本pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyqt55.15.11 pyqt5-sip12.13.0如果还不行就直接去镜像站手动下载 wheel再用pip install ./pyqt5-5.15.11-xxx.whl本地安装。注意 32 位 Python 和 64 位 Python 的 wheel 不能混用。5.2 双击运行提示 no such table: books现象在 PyCharm 里点 Run 正常在资源管理器里双击 main.py 却报sqlite3.OperationalError: no such table: books。原因程序用相对路径db/book_manager.db连接数据库工作目录不同相对路径找不到文件SQLite 有个“特性”——文件不存在时不会报错而是自动建一个空文件所以后续查询就找不到表。解决把路径改成基于__file__的绝对路径import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) DB_PATH os.path.join(BASE_DIR, db, book_manager.db)改完后重启程序然后打印一次DB_PATH确认它指向的文件确实存在。这个踩坑记录在每届课程设计里都会出现几乎已经成为惯例。5.3 表格显示乱码或中文变成问号现象QTableWidget 里显示澶?煡璇?或者???从 SQLite 读出来的中文全部损坏。原因SQLite 本身存 UTF-8 没问题但代码文件如果保存为 GBK或者你从旧 Windows 系统拷出数据库字符串编码错位就会发生。Python 3 的字符串是 Unicode但当数据库文件里实际存的字节与声明编码不一致时读出来就是乱码。解决所有源文件统一保存为 UTF-8并在每个.py文件第一行写# -*- coding: utf-8 -*-。如果数据库里的脏数据已经存在可以用 Python 脚本尝试按latin1转回utf-8清洗一遍rows conn.execute(SELECT id, title FROM books).fetchall() for rid, title in rows: fixed title.encode(latin1).decode(utf-8) conn.execute(UPDATE books SET title? WHERE id?, (fixed, rid)) conn.commit()注意这个方法只在数据确实是 UTF-8 字节被错误解码时才有效。如果清洗后还是乱码就得重新录入数据。5.4 删除/修改后表格刷新了重启后又是旧数据现象界面上点删除表格内那一行没了关闭程序重新打开那本书又回来了。原因DAO 在执行DELETE后没有调用commit()。SQLite 默认在一个隐式事务里只有commit()才会真正落盘。程序没报错是因为查询还在同一个连接上缓存里能看到“删除后”的表但重启后就没了。解决所有写操作后调commit()。最省心的办法是连接时开启自动提交conn sqlite3.connect(DB_PATH, isolation_levelNone)这样每次execute都会自动提交适合单机课程设计但像前面借书那种需要事务的业务还是要手动commit()和rollback()不能把所有操作都交给自动提交。5.5 循环里绑定按钮点击全部操作最后一本书现象图书列表每一行都放了一个“借书”按钮运行时点击第一行却借了最后一本书或者提示 id 越界。原因clicked信号不带参数lambda 捕获的是循环变量本身循环结束后变量停留在最后一个值。解决使用默认参数固化当前值btn.clicked.connect(lambda checkedFalse, bidrow[id]: self.borrow_book(bid))或者用functools.partialfrom functools import partial btn.clicked.connect(partial(self.borrow_book, row[id]))两种写法效果一样都能把当前row[id]锁在闭包里。遇到这个坑时控制台通常不会报错所以只能靠逻辑推导出来。6. 用 PyInstaller 打包成 exe验证管理系统能否独立运行课程设计提交时老师电脑上不一定装了 Python 环境把这个管理系统打成一个 exe 最省事。PyInstaller 是社区里最常用的打包工具对这个项目我会这样做pip install pyinstaller6.3.0 pyinstaller -w -F main.py --name BookManager --paths. --add-data docs;docs --hidden-importPyQt5.sip-w表示不显示控制台窗口-F打成单个 exe 文件--paths.让打包器能解析项目里的db和ui目录--add-data docs;docs把说明文档带进包--hidden-importPyQt5.sip强制包含底层绑定库否则部分机器上会报No module named PyQt5.sip。注意-F单文件模式运行时会把文件解压到临时目录导致__file__不再指向项目目录数据库路径会失效。一个常用技巧是在代码里判断是否被打包import sys, os if getattr(sys, frozen, False): BASE_DIR os.path.dirname(sys.executable) else: BASE_DIR os.path.dirname(os.path.abspath(__file__)) DB_PATH os.path.join(BASE_DIR, db, book_manager.db)这样 exe 会从可执行文件所在目录去读取数据库文件。打包后如果双击没反应建议先不加-w重新打一个带控制台的版本双击后看 traceback问题会直接在终端里打印出来。我接手过的很多 PyQt5 项目最终翻车都发生在打包后的动态库缺失或资源路径错误上把带控制台的版本跑一次基本都能快速定位。做完这一步再拿去提交运行环境对你的系统来说就不再是黑匣子了。希望帮到你。本文还有配套的精品资源点击获取