序言:世界上没有完美的东西,所以我只能尽量让他完美,该项目虽小,但包含了许多 Python GUI 编程的知识,希望通过这个小项目帮助你获得构建大项目的能力。我花费了一个月的时间经过多次重构,让源码尽可能的简单。之后我会基于该项目开发 Web 网页,网络爬虫,机器学习等内容。你可以将他作为一个综合性很强的动手实例, 通过一定的启发帮助你完成自己的创意。祝你玩的愉快,如果你喜欢的话可以关注我的 小红书号(锦沐Python):PASSLINK, 微信公众号与之同名。对了,我第一次写文档,如果有不足之处你可以通过客服窗口或私信方式进行反馈,我会根据反馈情况及时调整内容,感谢你的理解。或者你可以加入我的 QQ 交流群:615658845
项目目的
本项目以简单的图书管理需求,通过 PyQt5 库编写 GUI 程序,分享一个较为完整的开发流程,一些可视化功能的参考示例,包括图表显示,自定义列表,无边框模式,多线程模式,日志打印,自定义弹窗,文件导入导出,权限管理,打包技术等。除了技术层面其实我想分享的是面向对象思想,软件结构化思想,代码规范,设计思想。如果你觉得创意想法难以实现,可能不是难不难的问题,而是不熟悉解决问题的路径。
为什么使用 PyQt5 ?因为它功能强大,可以实现优美的UI,并且使用 Python实现,其工具小巧精美,非常适合可视化编程展示。为什么不用 C++? C++晦涩难懂,不适合小白,用 C++ 只会把我们的交流空间变的更加狭窄。当然,了解PyQt5再上手C++可能会更容易一些,至少你知道自己要做些什么。
为什么本项目有一些BUG?我是个完美主义者,但我逐渐相信:世界上没有完美的东西,所以我在说服自己接收不完美的事实。如果你发现难以修复的BUG,可以在本文末尾留言,我看到后会及时回复并更新到文档中哦。
阅览建议
本项目需要一定的 python 基础,如果你还没有了解过 python,你可以访问并参考以下链接:
Python3 教程 | 菜鸟教程 (runoob.com)
在项目开始前,了解 python 的基础语法是很有必要的,因为项目文档不会列举冗杂的基础知识,这会影响阅读体验,减弱我们的目标。因此以下内容是你必须有所了解的;
- 列表,字典,元组 list, dict, tuple
- 控制流语句 if else , for ,while
- 函数的定义
- 模块与包 import
- 异常处理 try-except
- 了解 PyQt5
以上内容你可以花费大约总共20个小时阅览参考文档内容,参阅过程建议你把示例代码复制跑一下,有余力的可以尝试修改代码观察变化。以上工作准备完成后,我们就可以开始了。
文档主要顺序为:需求分析,数据库设计,UI设计,代码规范,代码编写(由于项目较小,测试部分已删除),程序压缩与打包,制作安装器。阅览时,你可以打开项目源码,按照文件名提示观察内容。如果你发现有些地方与文档不对应,可能是文档没有及时更新,你不必担心,因为改动一般会很小,只是涉及一些代码优化,总体上其实是一样的。
需求概述
开始一个项目的目的就是满足需求,而需求就是一个抽象的大问题,这个问题与实际有密切联系,作为开发者视角,你就是解决问题的人。解决问题的前提是理解问题,然后将问题分解,使用合适的方法解决问题。本项目需要 Python 语言编写,通过 PyQt5 构建图形界面对图书数据进行管理,其主要功能包括:
1.录入图书:
在管理员模式下,录入图书信息,包含:图书名称,作者,ISBN,书籍的类别,出版社,出版时间。
2.图书更新:
在管理员模式下,通过图书编号查找图书信息并进行更新:图书名称,作者,ISBN,书籍的类别,出版社,出版时间。
3.删除图书:
在管理员模式下,通过查找图书编号,对图书信息进行删除。
4.查询图书:
在管理员模式下,可以通过图书编号进行查询,并展示 图书名称,编号,作者,ISBN,书籍的类别,出版社,出版时间,录入时间,借阅人,归还时间 信息。
在普通用户模式下,可以通过图书编号进行查询,并展示图书名称,编号,作者,ISBN,书籍的类别,出版社,出版时间,录入时间。
5.统计功能
管理员可以查看,借阅情况,图书种类,出版时间统计,通过关键词查询借阅情况,书名统计。
普通用户可以查看自己的借阅情况,借阅历史,通过关键词查询借阅情况,借阅的书名统计。
6.借阅功能
普通用户可以通过图书编号借阅-未借阅的书籍。已借阅的书籍在查询页放置到最前面。(普通用户只能修改图书借阅状态)
7.用户注册功能
填写学号,用户名,密码,邮箱,角色信息进行注册操作。
8.用户登陆
用户填写学号,密码,并根据角色进入不同的管理界面。
9.修改密码
用户登陆后可以修改学号,用户名,密码,邮箱,角色信息。
- 用户注销时从数据库删除用户信息
- 使用 json文件 作为数据库持久化数据
需求描述阶段我把重要的词语都加粗了,通过这些词语我们可以获取以下信息:
- 用户分为两种角色:管理员,普通用户
- 书籍信息应当包含:图书名称,编号,作者,ISBN,书籍的类别,出版社,出版时间,录入时间,借阅人,归还时间 ,借阅周期(通过归还时间推测),借阅时间(通过归还时间推测)
- 用户信息应当包括:学号,用户名,密码,邮箱,角色信息,(拓展信息:注册时间,账号状态 )
- 数据库使用 JSON 文件,也就是使用 JSON 格式存储数据
- 涉及用户信息的主要功能包括:登录,注册,更新用户信息,图书借阅
- 涉及书籍信息的主要功能包括:图书录入,图书查阅,图书更新(修改,借阅),图书删除,图书统计。
什么是JSON?
简单来说就是固定格式的字符串,与 Python 的字典格式非常相似,使用花括号(对象)包裹键值对,值可以是列表,数值,字符串,对象,键是字符串。
{ "a":["345","23456"],
"b":123,
"c":"2345678",
"d":{
"test":"tesx",
"s":123,
}
}
什么是抽象?
本文的抽象意思是利于人脑理解的表达方式,不和艺术上的抽象有关联。
数据库设计
根据分析我们可以获取许多重要的字段,我们可以对他们进行分类处理,接下来我们把用户信息和图书信息抽象为对象。根据实际生活把他们都分到对应的实体(对象)上。于是,我们可以列出以下详细的参数表格。参数名使用英文,意义上与中文一样。
用户
Field | Value |
user_id | 学生学号(主键:唯一的标识) |
user_name | 学生姓名或其他字符串(仅限字母和数字以及下划线组合) |
| 仅限邮箱格式字符串 |
role | 管理员或普通用户 admin | user |
password | MD5加密字符串 |
account_status | 0 正常(0),注销(1)(本项目不对其修改,你可以进行拓展应用) |
registration_time | 注册时间 格式: 2024.07.28 20:51:04 (要求自动生成) |
#json 持久化数据格式示例,json 其实就是一种固定格式的字符串,类似Python里的字典
{
"user_id": "123456",
"user_name": "123456",
"email": "123456@qq.com",
"role": "admin",
"password": "e10adc3949ba59abbe56e057f20f883e",
"account_status": 0,
"registration_time": "2024.07.28 20:51:04"
}
图书
当我们想到用户要与图书管理系统交互时,其本质就是对图书数据集合的增加,删除,更新,查询操作。既然作为集合,那么就有很多书籍信息放到一起,如何简化它们,并管理他们?实际生活中我们会填写表格去管理他们,现在也是。于是我们赋予每本书籍一个表格,除了需求里的字段外,还增加了图书ID,图书封面信息。
Field | Value |
book_id | 字符串(主键) |
book_name | 书名 |
ISBN | 图书ISBN码 |
record_time | 管理员录入该图书时间 格式:2024.07.27 13:20:15 |
category | 文学、工科、外语文献 |
author | 图书作者,可包含多个 |
publisher | 图书出版社 |
publication_date | 年月 2017.07 |
borrowed_by | 用户学号: 用户Id 空字符代表未借 | 否则已借 |
borrow_time | 用户借阅时间 2024.07.27 13:20:15 |
loan_period | 图书可以借阅的天数(本项目不进行修改,你可以进行拓展) |
due_time | 借阅时间加上可借天数 归还期限(自动计算) |
img_url | 图书封面路径 相对 main.py 的路径 upload_imgs\1721902002.935139.png |
#json 持久化数据格式示例,编码问题,你可能看不到中文
{
"book_id": "26caace26e",
"book_name": "\u9053\u5bb6\u6587\u5316\u4e0e\u4e2d\u533b\u5b66",
"ISBN": "900000000",
"record_time": "2024.07.27 13:20:15",
"category": "R-092",
"author": "\u6c5f\u5e7c\uff0c\u674e\u539f\u64b0",
"publisher": "\u5317\u4eac\u4e2d\u56fd\u4e2d\u533b\u836f\u51fa\u7248\u793e",
"publication_date": "2017.07",
"borrowed_by": "123456",
"borrow_time": "2024.07.17 13:20:15",
"loan_period": 10,
"due_time": "2024.07.27 13:20:15",
"img_url": "upload_imgs\\1721902002.935139.png"
}
为了方便管理,两种数据存储到一个 json 文件,当然如果数据量很大,分为两个 json 文件存储更佳
{
"users": [
{
"user_id": "123456",
"user_name": "123456",
"email": "123456@qq.com",
"role": "admin",
"password": "e10adc3949ba59abbe56e057f20f883e",
"account_status": 0,
"registration_time": "2024-07-28 20:51:04"
}
],
"books": [
{
"book_id": "26caace26e",
"book_name": "\u9053\u5bb6\u6587\u5316\u4e0e\u4e2d\u533b\u5b66",
"ISBN": 900000000,
"record_time": "2024.07.27 13:20:15",
"category": "R-092",
"author": "\u6c5f\u5e7c\uff0c\u674e\u539f\u64b0",
"publisher": "\u5317\u4eac\u4e2d\u56fd\u4e2d\u533b\u836f\u51fa\u7248\u793e",
"publication_date": "2017.07",
"borrowed_by": "123456",
"borrow_time": "2024.07.17 13:20:15",
"loan_period": 10,
"due_time": "2024.07.27 13:20:15",
"img_url": "upload_imgs\\1721902002.935139.png"
},
//其他书籍数据 我准备了大约5000本关于医学的书籍信息
]
}
E-R关系图分析(非标准)
当图书和用户属性确定后,我们可以将他们之间的逻辑简单画出来,从下图中可以看出用户与图书是一对多的关系,操作主要是增删改查。
数据流图分析(非标准)
管理员与普通用户都属于用户,二者拥有不同的权限,根据权限我们可以为他们分配不同的功能。
UI 设计
上面的步骤完成后我们就可以开始进行UI设计了,我们使用绘图工具 Pixso 把想象画面绘制出来,在之后的代码编写中有参考作用。设计图与实际效果有一定差异,因为你开始不一定都是最好的,你在实现的过程中是不断地迭代,不断地更新设计图的,只有设计,再设计,不断的完善才会让软件效果变的更加理想一些。我很懒,设计图不经常更新,所以以实际运行效果为准。
设计阶段,我想自定义边框,由于常见的窗口都是标准的直角矩形,无法调整边框样式,所以我把每个界面分为了两个部分,标题栏与内容。
软件结构设计
当设计图出来后,你可以开始思考如何管理好项目,使用什么样的方法让项目更具结构化,或者更容易让别人看懂。一个好的结构不仅利于编码,也让程序更加稳固,至少不会有太多 bug 要修复。
三层结构
- 数据层:读写 json 文件数据,为业务层提供增删改查接口。使用 RepositHelper 统一管理数据层接口。
- 业务层:通过调用数据层接口获取原始数据,为视图层直接提供加工后的数据结果。使用 ServicesHelper 统一管理业务层接口。
- 显示层:可以看到的窗口,通过交互事件调用业务层获取数据,然后进行显示或其他处理。
作为用户,与窗口内组件进行交互时,依次访问 视图层-->业务层--> 数据层-->业务层-->视图层,比如查询书籍按钮,调用业务层获取书籍信息的接口,业务层调用数据接口获取原始数据,进行数据加工后返回视图层进行处理。
为什么要分层? 让代码结构清晰,利于功能拓展,也符合人的思维方式。当你需要完成其他需求时可以先分析它本质上的功能,然后对症下药,比如更换 数据层 为MySql,csv,Sqlite等。
项目文件夹
├─config 全局配置,单例模式
├─doc 项目文档
│ ├─design
│ └─python安装包 Python安装包,用于项目部署
├─repositories 数据层
│ ├─json 使用 json 文件进行数据持久化
│ │ ├─interface 数据层接口
│ │ ├─models 数据实体(用户,书籍)
├─services 业务层
│ ├─interface 业务层接口
├─upload_imgs 图片保存文件夹
├─views 显示层
│ ├─components 视图组件
│ ├─fonts 字体文件
│ ├─rcc 素材文件,PyqtDesigner 产生的资源文件 rcc
│ ├─style 样式文件参考(不参与项目,仅作展示)
│ ├─ui 不同的视图界面,PyqtDesigner 通过 ui 文件产生
├─tests 测试
├─dist Pyinstaller 打包程序
项目环境准备(以 Windows 为例)
你可以观看我的环境不是教学视频。
- Python 安装包获取项目 doc 目录下选择合适的版本进行安装
- 如果没有安装包可前往镜像源按需下载:安装包地址:python-release安装包下载_开源镜像站-阿里云 (aliyun.com)
- python 环境变量配置参考, 如果实在不会可以观看其他教程Windows :Python安装与环境配置,2022最新,超详细保姆级教程,python入门必备_python环境配置-CSDN博客MacOS : MacOS Python开发环境搭建 | Mac系统配置Python开发环境高清详细教程字幕版 | 全_哔哩哔哩_bilibili
- 如果你需要进行二次开发,请使用 PyCharm Community (社区版足矣)
Windows:PyCharm安装教程及基本使用(更新至2024年新版本),教你迈出学习python第一步-CSDN博客
MacOS:MacOS配置Python开发环境和Pycharm的详细步骤(完整版)_mac python环境搭建-CSDN博客
- 创建虚拟环境(为什么不用 Anaconda ?没必要,我觉得太臃肿了)Windows: 【Python基础】PyCharm配置Python虚拟环境详解_pycharm虚拟环境设置-CSDN博客MacOS:MacOS配置Python开发环境和Pycharm的详细步骤(完整版)_mac python环境搭建-CSDN博客
- 导入项目,将解压后的项目文件复制到创建虚拟环境的根目录下
- 安装依赖包
pip install -r requirement.txt
- 配置 PyQtDesigner python可视化编程(pyQT designer)安装及入门教程-CSDN博客
代码规范
代码命名规范
- 类名:使用大驼峰命名法,如 User , Book, MainWindoow
- 常数名:全部大写,单词使用下划线分割,如:BOOK_IMG_SAVE_DIR
- 函数名:使用小写与下划线,
动作_对象名_可选附加内容
。如 get_username_by_id。只在模块内使用的函数名统一加下划线前缀,_get_username_by_id - 文件名:使用小写与下划线,如 main_window
QtDesigner ui 命名规范
- 组件:使用小写与下划线命名,后缀必须添加组件名称。如:login_button, login_label
- 最高父级容器命名:使用大驼峰命名法, 如:AdminWidget, LoginWidget
- 转换的 py 文件名无需修改
Qt样式命名规范
使用Id选择器(#),不准分散,全部汇集在最高父级容器样式配置里。比如 AdminWidget 里的组件样式全部写在 AdminWidget 下。每个文件的内容你可以打开 style 文件夹查看,或者在 QtDesigner 里的顶级容器的属性里查看。
返回值规范
业务层,数据层等 返回值统一为元组形式 (falg, data),其中flag 为成功与否,只包含 True 或 False ;data 为任意数据类型。
代码实现
请注意,我只列举简单的示例,因为本项目就是以示例的思路实现其他重复性工作,你可以参考文档后查看源码获取更多信息。
数据层实现
为什么一开始就要写数据层?我认为距离数据越近,掌控数据的能力越强,也就更有把握,况且我们只需要完成增删改查功能即可。操纵数据就得了解数据,而数据层离真实的数据最近。前提是你已经完成了设计阶段,已经在脑海里想象出程序运行时是什么样子。
数据层只需要与业务层进行数据交互,因此我们只需要完成增加,删除,修改,查询功能,以及基本的 json 文件读写功能。
现在以用户 User 为例:
- 实体类定义 可以想象一下,把数据库每条用户信息都看作独立的对象,虽然他们的参数值不同,但属性值都是相同的。为了方便管理用户数据,将用户抽象为一个类,也就是定义一个模板(一个用户信息表格),当需要创建用户的时候只需要把参数值传入,你就可以在其他地方轻松引用。
{
"user_id": "1008",
"user_name": "henry_student",
"email": "henry@example.com",
"role": "user",
"password": "hashed_password_here",
"account_status": 0,
"create_time": "2023-09-01 13:20:00"
}
# user_models.py
"""
用户类,定义用户属性与方法
"""
class User:
"""
@ClassName:User
@Description: 用户实体类,映射数据库一条用户数据
负责属性赋值检查,可调用密码加密与效验比对
@Author:锦沐Python
参数:
user_id (str): 学生的 ID
user_name (str): 用户名
email (str): 电子邮箱
role (str): 用户角色 ("user"|"admin")
password (str): 密码
account_status (int): 账户状态(0 表示正常,1 表示冻结等)
create_time (str): 注册时间
"""
# 构造函数,初始化时可以直接传入参数,但本项目使用逐个赋值
# 为什么这样用,因为我想使用 @property 和 @属性名.setter
#在Python中,@property 和 @<attribute>.setter 是用来定义属性的一种方式。
#这些装饰器允许你在类中使用类似属性访问的语法来访问和修改对象的属性,而不需要直接调用方法。
# 我们可以在赋值时触发 @<attribute>.setter 包装的函数对数据进行检查,这样就不需要在其他地方重复检查步骤了
# 例如:
# user = User()
# user.user_id = "12345678" 会触发 @user_id.setter 装饰的函数,检查 "12345678" 字符串长度是否大于6
def __init__(self, user_id="", user_name="",
email="", role="user", password="",
account_status=0, create_time=""):
self._user_id = user_id
self._user_name = user_name
self._email = email
self._role = role
self._password = password
self._account_status = account_status
self._registration_time = create_time
@property
def user_id(self):
return self._user_id
@user_id.setter
def user_id(self, user_id):
# 对赋值数据进行检查
if isinstance(user_id, str) and len(user_id) >= 6:
self._user_id = user_id
else:
raise ValueError("user_id 必须是长度至少为 6 的字符串")
# 其他.......
- 定义接口:
什么是接口?接口定义了一组方法、属性或其他操作,而不包含具体的实现细节。它规定了其他程序元素(如类或模块)与这些功能或服务的交互方式。说白了就是定义一组规则,要求 输入参数,输出参数的格式,数量等。只要遵顼这些规则就会让代码更加灵活。
接口的主要特点包括:
- 抽象性:接口描述了某个实体提供的功能,而不涉及具体的实现细节。它定义了行为的契约或协议,但并不关心具体的实现方式。
- 规范性:接口定义了使用者可以期待的方法和属性。这种规范性使得不同的实现可以在行为上保持一致,从而提高了代码的可扩展性和可维护性。
- 分离关注点:接口将代码模块化,允许不同的组件彼此独立地开发、测试和维护。使用接口可以降低代码的耦合度,提高系统的灵活性和可替代性。
为什么使用接口?试想,当你需要修改某个函数里的算法时,你只需要关心输入与输出,无需关心这个接口被谁使用,被如何使用,会对其他部件造成什么影响,你只需要按规则保证输入输出格式正确即可。
# user_data_interface.py
"""用户表的增删改查接口
"""
from abc import ABC, abstractmethod
from LibrarySystem.repositories.json.models.user_models import User
class UserDataInterface(ABC):
"""
@ClassName:UserDataInterface
@Description: 用户数据层接口定义
包含增删改查功能
@Author:锦沐Python
"""
# 此处我们定义了一个增加用户的接口叫做 add_user ,并要求传入参数必须是一个 User() 对象
@abstractmethod
def add_user(self, user:User):
pass
- 实现接口
定义好了增删改查的规则,我们就要实现它的逻辑部分了,也就是如何处理参数,并返回数据。下面展示了 增加用户接口 具体如何实现,也就是如何把传入的用户数据存储到 json 文件
# user_repository.py
# 我们需要继承 接口类 并实现所有接口
class UserRepository(UserDataInterface):
"""
@ClassName:UserRepository
@Description: 继承用户数据层接口类,本项目只创建一个实例
负责实现接口功能
@Author:锦沐Python
"""
def __init__(self):
self.file_name = "db.json"
self._is_file_exists()
# 实现增加用户接口
def add_user(self, user: User):
"""
增加用户到数据库
:param user:
:return: (操作结果标志, 数据|提示信息)
"""
user_id = user.user_id
data_to_write = user.obj_to_dict()
if data_to_write is None:
module_logger.info("用户数据为空")
return (False, "用户数据为空")
try:
data_json = self._read_file()
# 遍历查找,防止用户 id 重复
for user_ in data_json["users"]:
if user_["user_id"] == user_id:
module_logger.info("users 已存在")
return (False, f"用户 {user_id} 已存在")
data_json["users"].append(data_to_write)
# 写入数据
self._write_file(data_json)
return (True, f"用户 {user_id} 添加成功")
except Exception as e:
# 处理其他可能的异常
module_logger.info(f"添加用户失败:{e}")
return (False, f"添加用户失败 {user_id}: {e}")
# 读取文件内容
def _read_file(self):
with open(self.file_name, 'r', encoding='utf-8') as file_obj:
data_json = json.load(file_obj)
return data_json
def _write_file(self, data_json):
# 写入数据
with open(self.file_name, 'w', encoding='utf-8', buffering=1024*1024) as file_obj:
json.dump(data_json, file_obj, indent=4)
# 你可以在其他要增加用户的地方调用他了
# 你需要完成两步,实例化一个 UserRepository 类,调用他的 add_user 函数并接收返回信息
user = User()
user.user_id = "3456785678"
# 其他属性赋值
user_repository = UserRepository()
(flag, msg) = user_repository.add_user(user)
# 其他操作,比如根据结果进行信息提示
- 统一管理接口 虽然我们只有两个实体,也就是只需要管理 用户数据和图书数据 ,但如果还有其他很多实体就得在一个需要很多接口的模块了导入很多接口实现类,并对他们进行实例化,这不仅显得代码复杂,还占用许多内存资源。为此我们可以创建一个管理类,将接口 实现类 统一管理,让接口 实现类 只在程序运行中存在一个即可。
# RepositoryHelper.py
class RepositoryHelper:
"""
@ClassName:RepositoryHelper
@Description:单例模式, 统一管理数据层接口实例
负责创建唯一数据接口实例,提供给 业务层 访问
@Author:锦沐Python
"""
_user_repository = None
_book_repository = None
@classmethod
def get_user_repository(cls):
if cls._user_repository is None:
cls._user_repository = UserRepository()
return cls._user_repository
@classmethod
def get_book_repository(cls):
if cls._book_repository is None:
cls._book_repository = BookRepository()
return cls._book_repository
#@classmethod 是 Python 中的一个装饰器,用于定义类方法。类方法是绑定到类而不是实例的方法,
#因此可以通过类直接调用,而不需要先创建类的实例。RepositoryHelper.get_user_repository() 即可获取一个 UserRepository()实例,然后再调用他的方法即可。
# RepositoryHelper.get_user_repository().add_user(user)
# 如果你嫌代码太长,可以分开写
# user_repository = RepositoryHelper.get_user_repository()
# user_repository.add_user(user)
此时我们已经完成了一个数据层的小功能,其他接口都是一样的流程。
业务层实现
业务层就是将数据处理后传递给 数据层 或 视图层,他作为中间者可以对数据进行加工。
- 定义接口: 依然使用 User 作为示例,下面展示 增加用户的业务层接口
# user_service_interface.py
class UserServiceInterface(ABC):
# 你看到函数名了吗,和数据层的一样,但并不是一回事,一码归一码,函数名一样利于人理解,顾名思义
@abstractmethod
def add_user(self, user: User):
pass
- 实现接口:
# user_service.py
class UserService(UserServiceInterface):
"""
@ClassName:UserService
@Description: 继承用户业务层接口类,本项目只创建一个实例
负责实现接口功能
@Author:锦沐Python
"""
def add_user(self, user: User):
# 调用数据层接口。有时我们不需要调用数据层,只需要处理数据,比如传入列表排序,返回结果即可。
(flag, msg) = RepositoryHelper.get_user_repository().add_user(user)
# 一些数据处理,比如用户属性值更改,格式处理等。因为该接口简单,所有我们不处理什么
# 返回处理结果给视图层
return (flag, msg)
- 统一管理接口:
# ServicesHelper.py
class ServicesHelper:
"""
@ClassName:ServicesHelper
@Description:单例模式, 统一管理 业务层接口 实例
负责创建唯一业务接口实例,提供给 GUI层 访问
@Author:锦沐Python
"""
_user_service = None
@classmethod
def get_user_service(cls):
if not cls._user_service:
cls._user_service = UserService()
return cls._user_service
此时我们已经完成了业务层的一个小功能。
视图层实现
视图层比较复杂,要和许多控件进行交互。简单起见,我们以注册功能为例,介绍视图层是如何实现注册功能的。
注册要完成什么功能?把检查通过的用户输入信息存到数据文件 db.json 里。也就是在db.json的users字段里添加一条新的用户信息。
配置可以查看 QtDesigner 配置
- QtDesigner ui 视图创建 当你拿到设计图后,就要使用 PyQtDesigner 工具进行构建了,观察设计图的注册部分,你可以看到几个输入框和一个注册按钮。
此时,你就要在 PyQtDesigner 工具里放置 五个 输入框,并依次对他们进行有效的命名:
- reg_user_id_edit
- reg_user_name_edit
- reg_email_edit
- reg_password_edit
- reg_role_select
- reg_ok_button
完成后后将保存的 ui 文件通过 **QtDesigner **工具转换为 py 文件,你可以打开它观察最末尾部分,可以看到:
# LoginWidget.py
class Ui_LoginWidget(object):
def setupUi(self, LoginWidget):
LoginWidget.setObjectName("LoginWidget")
LoginWidget.resize(900, 700)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(LoginWidget.sizePolicy().hasHeightForWidth())
LoginWidget.setSizePolicy(sizePolicy)
LoginWidget.setMinimumSize(QtCore.QSize(900, 700))
# 其他代码 ......
def retranslateUi(self, LoginWidget):
_translate = QtCore.QCoreApplication.translate
LoginWidget.setWindowTitle(_translate("LoginWidget", "Form"))
self.go_to_reg_button.setText(_translate("LoginWidget", "没有账号?去注册"))
self.reg_user_id_edit.setPlaceholderText(_translate("LoginWidget", "请输入学号"))
self.reg_user_name_edit.setPlaceholderText(_translate("LoginWidget", "请输入用户名"))
self.reg_email_edit.setPlaceholderText(_translate("LoginWidget", "请输入邮箱"))
self.reg_password_edit.setPlaceholderText(_translate("LoginWidget", "请输入最少6位密码"))
self.reg_role_select.setPlaceholderText(_translate("LoginWidget", "请选择角色"))
self.reg_ok_button.setText(_translate("LoginWidget", "注册"))
# 注意:最后会引入 import my_rcc_rc 可以直接删除,我们在主函数模块引入一次即可
你可通过搜索看到变量原始的样子,比如:
self.reg_user_id_edit = QtWidgets.QLineEdit(self.widget_25)
self.go_to_login_button = QtWidgets.QPushButton(self.widget_24)
- 使用视图 py 文件
由于视图文件改动频度高,如果对其进行直接修改,下一次转换会直接覆盖修改内容,所以我们应该只引用它,不修改他。
首先新创建一个文件 login_widget.py 引入 Ui_LoginWidget 类,并继承它,获取他所有的属性
# login_widget.py
class LoginWidget(QWidget, Ui_LoginWidget):
"""
@ClassName:LoginWidget
@Description:本项目只创建一个 LoginWidget 窗口实例,
其包含登录和注册界面
负责用户登录和注册操作,错误信息使用弹窗方式告知用户
@Author:锦沐Python
"""
def __init__(self):
# 调用 QWidget 的构造函数进行初始化
super().__init__()
# 初始化 Ui_LoginWidget
self.setupUi(self)
self.ui_init()
def ui_init(self):
"""
连接按钮动作,点击触发相关函数
@return:
"""
# 此处我们可以直接访问 Ui_LoginWidget 的组件了,现在我们连接注册按钮的槽函数。
# 也就是点击按钮后执行 register() 函数
self.reg_ok_button.clicked.connect(self.register)
def register(self):
"""
将用户输入的正确信息存储到数据库
:return:
"""
# 创建一个新的用户对象
user = User()
# 还记得实体类包含了检查功能吗,如果检查错误就抛出 ValueError 异常
try:
# 从输入框里获取用户输入,进行赋值的同时也完成了检查
user.user_id = self.reg_user_id_edit.text()
user.user_name = self.reg_user_name_edit.text()
user.email = self.reg_email_edit.text()
user.role = self.reg_role_select.currentText()
user.password = self.reg_password_edit.text()
user.create_time = str(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 调用业务层 add_user() 接口存储用户信息
(flag, msg) = ServicesHelper.get_user_service().add_user(user=user)
if flag is False:
print(f"注册失败:{msg}")
return
# 其他操作.......
print("注册成功")
except ValueError as e:
module_logger.error(f"注册失败:{e}")
self.messager.show_msg(f"注册失败:{e}", "error")
此时我们已经完成了视图层,回看整个流程,是不是非常简单,但别太高兴,因为还有许多细节需要处理呢!!!
当你第一次看到下面这句时可能会困惑,为什么这样写。这个其实是固定写法,self.registe() 叫做槽函数, connect() 叫信号槽。因为在 Python 里传递参数传的是参数的引用,就像C语言里的指针,这里填写 self.register 就是将函数引用传入到 connect() 函数里,当我们点击时他就会 在后面加个括号 self.register() 执行函数内容。如果你直接传入connect(self.register()) 意思就不对了,此时你传入的参数是self.register()函数处理后返回的结果,很明显我们本意不是这样的。 所谓信号就是要有发送方,接收方,接收方按照发送方指示进行对应操作。
self.reg_ok_button.clicked.connect(self.register)
显示窗口:
本项目只创建一个窗口实例,所有页面均在一个窗口里显示。你只需要将自定义的 Ui 类添加进 self.central_widget 里的self.stackedWidget 即可。
# main_window.py
# 继承窗口类 QMainWindow
class MainWindow(QMainWindow):
"""
@ClassName:MainWindow
@Description:本项目只创建一个 MainWindow 窗口实例,
初始化时会将所有页面组装,包括登录、注册页面,以及管理后台页面
负责无边框状态下,窗口大小伸缩,并将自定义标题栏添加到顶部
@Author:锦沐Python
"""
def __init__(self):
super().__init__()
self.config = Config()
self.ui_init()
def ui_init(self):
"""
组装页面,并初始化页面,将登录页作为首页
"""
# 创建子组件
self.title_bar = TitleBar(self)
self.login_widget = LoginWidget()
self.admin_widget = AdminWidget()
# 创建一个新的 QWidget 作为 主窗口根容器,容纳所有子组件
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
# 创建堆栈窗口,用于装载 LoginWidget AdminWidget 组件
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.addWidget(self.login_widget)
self.stackedWidget.addWidget(self.admin_widget)
self.stackedWidget.setCurrentIndex(0)
# 创建垂直布局,用于装载 TitleBar QStackedWidget 组件
self.layout = QVBoxLayout(self.central_widget)
self.layout.addWidget(self.title_bar)
self.layout.addWidget(self.stackedWidget)
# main.py
if __name__ == "__main__":
Config()
app = QApplication(sys.argv)
main_window = MainWindow()
# 显示窗口
main_window.show()
sys.exit(app.exec_())
日志打印:
我们经常使用 print()函数打印信息进行调试,但有时在一个项目里,我们需要更准确的信息,比如一些错误提示,我们可以根据错误标注等级,时间,保存到文件等以进行更好的查找问题位置。我们只需要调用 Python 内置的 logging 库即可。
"""
@FileName:logger.py\n
@Description:\n
@Author:锦沐Python\n
@Time:2024/7/21 20:47\n
"""
import logging.config
# 日志配置,名称为 logger 项目只使用 logger
logger = logging.getLogger("logger")
logger.setLevel(level=logging.INFO)
handler = logging.FileHandler("log.txt")
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(filename)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(formatter)
logger.addHandler(handler)
logger.addHandler(console)
在启动模块里导入它就能直接运行配置,之后你在其他文件里使用只要以下代码:
import logging
module_logger = logging.getLogger("logger")
module_logger.error(f"上传图片失败:{e}")
module_logger.info(f"你太棒了")
全局配置
有时我们需要统一管理一些变量,或者提供一些常量,比如文件访问路径,保存路径等,在需要的地方访问变量名即可,修改时,只需要修改配置内容即可。因为项目较小,我直接使用一个配置类作为全局变量的访问类。当你需要配置信息时直接访问获取即可。
# -*- coding:utf-8 -*-
"""
全局配置文件,提供常量信息
"""
import os
import logging
import sys
module_logger = logging.getLogger("logger")
class Config():
"""
@ClassName:Config
@Description: 本项目只创建一个 Config 实例
负责常用属性配置,包括窗体颜色,文件路径,字体等
@Author:锦沐Python
D:\A-XHSPro\PyQt5Pro\LibrarySystem
"""
_instance = None # 类变量,用于存储唯一实例
# 主函数启动的绝对路径
CURRENT_PATH = os.path.abspath('.')
try:
CURRENT_PATH = sys._MEIPASS # pyinstaller打包后的路径
except AttributeError:
CURRENT_PATH = os.path.abspath(".") # 当前工作目录的路径
# 图书封面保存文件夹
BOOK_IMG_SAVE_DIR = "upload_imgs"
# 统计图保存文件夹
STATS_IMG_SAVE_DIR = "stats_imgs"
# 图书图片保存路径
BOOK_IMG_SAVE_PATH = os.path.join(CURRENT_PATH, BOOK_IMG_SAVE_DIR)
# 统计图片路径
STATS_IMG_SAVE_PATH = os.path.join(CURRENT_PATH, STATS_IMG_SAVE_DIR)
# 数据持久化文件路径(json)
DATA_JSON_PATH = os.path.join(CURRENT_PATH, "repositories\\json\\data.json")
# 字体文件路径
FONT_PATH = os.path.join(CURRENT_PATH, "views\\fonts\\Alibaba-PuHuiTi-Regular.ttf")
@staticmethod
def create_directory(directory):
if not os.path.exists(directory):
os.makedirs(directory)
module_logger.info(f"创建文件夹{directory}")
else:
module_logger.info(f"文件夹{directory}已存在")
def __new__(cls):
# __new__ 方法确保只创建一个实例
if not cls._instance:
cls._instance = super().__new__(cls)
cls.create_directory(cls.BOOK_IMG_SAVE_PATH)
cls.create_directory(cls.STATS_IMG_SAVE_PATH)
return cls._instance
# 在其他类里使用 Config
def __init__(self):
# 获取配置实例
self.config = Config()
def record_saveImage(self):
"""
添加图书界面_图片,将选择的图片插入页面,并回显路径名称
"""
try:
# 其他代码.....
# 保存图片
# 我想你也不希望更改保存地址的时候到处查找吧
save_path = self.config.BOOK_IMG_SAVE_PATH + f"\\{img_name}"
pixmap.save(save_path)
# 显示保存路径
self.record_book.img_url = self.config.BOOK_IMG_SAVE_DIR + f"\\{img_name}"
except Exception as e:
module_logger.error(f"上传图片失败:{e}")
弹窗提示
友好的信息提示可以让你的程序更加人性化,你至少得让用户知道一些信息吧,比如登录失败,信息检查错误提示等。这个功能用的肯定很频繁,我们可以创建一个类直接进行管理,要用的时候调用即可。
# messager.py
# 你可以自定义窗口样式然后继承他 Ui_Messager
class Messager(QDialog, Ui_Messager):
"""
@ClassName:Messager
@Description:Messager 创建一个弹窗,
本项目创建的弹窗实例不会被主动删除,因此可以重复使用一个实例,
负责必要的信息提示
注意:只能在窗体类内创建实例,其他地方创建会导致无法启动程序
@Author:锦沐Python
"""
def __init__(self):
super().__init__()
self.setupUi(self)
# 设置窗口始终在顶部,隐藏问号
self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.WindowCloseButtonHint)
self.time_label.setText(datetime.now().strftime("%Y-%m-%d %H:%M:S"))
def show_info(self, msg: str):
self.msg_label.setStyleSheet("color: blue;")
self.msg_label.setText(str(msg))
self.show()
def show_warn(self, msg: str):
self.msg_label.setStyleSheet("color: yellow;")
self.msg_label.setText(str(msg))
self.show()
def show_error(self, msg: str):
self.msg_label.setStyleSheet("color: red;")
self.msg_label.setText(str(msg))
self.show()
在视图层里需要的地方引用即可,以注册为例
# self.messager = Messager()
def register(self):
"""
将用户输入的正确信息存储到数据库
:return:
"""
user = User()
try:
user.user_id = self.reg_user_id_edit.text()
user.user_name = self.reg_user_name_edit.text()
user.email = self.reg_email_edit.text()
user.role = self.reg_role_select.currentText()
user.password = self.reg_password_edit.text()
user.create_time = str(datetime.now().strftime("%Y.%m.%d %H:%M:%S"))
# 存储用户信息
(flag, msg) = ServicesHelper.get_user_service().add_user(user=user)
if flag is False:
self.messager.show_error(msg)
return
# 配置用户
(flag, msg) = ServicesHelper.get_role_service().set_current_role(user)
if flag is False:
self.messager.show_error(msg)
return
# 注册成功自动跳转到管理页
# 发送给MainWindow的跳转信号
q_signals.LoginToAdmin_Signal.emit()
# 发送给AdminWidget的传参信号
q_signals.LoginUserInfo_Signal.emit()
module_logger.info("注册成功")
except ValueError as e:
module_logger.error(f"注册失败:{e}")
self.messager..show_error(f"注册失败:{e}", "error")
图表嵌入视图
你可能会好奇,图表是怎么放到程序里显示的,而且用 matplotlib 库进行绘制图表。非常幸运的是,matplotlib 自带 Qt 组件,我们只要正确使用它即可,不过缺点就是不太容易实现动态图表。下面我们以静态为示例,绘制图书管出借数量情况的饼图。
我们依然创建了一个类,不过继承了 FigureCanvasQTAgg 类,用于兼容 Qt 程序,将图像嵌入到容器里。
# -*- coding:utf-8 -*-
"""
图像
"""
from random import random
import logging
from datetime import datetime
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from LibrarySystem.config.config import Config
mpl.use('Qt5Agg')
# 防止中文乱码
plt.rcParams['font.sans-serif'] = ['SimHei']
module_logger = logging.getLogger("logger.sub")
class CustomFigure(FigureCanvas):
"""
@ClassName:CustomFigure
@Description:CustomFigure 映射一个统计图表,可以存在多个
负责统计页面里的图表绘制,初始化时接收 图像名称
@Author:锦沐Python
"""
def __init__(self, name):
self.fig = Figure(figsize=(6, 6))
super().__init__(self.fig)
self.name = name
self.axes = self.fig.add_subplot(111)
self.config = Config()
# 散点图
def create_scatter(self, x, y, title, x_label="", y_label=""):
module_logger.info("开始创建散点图")
self.axes.clear()
self.axes.scatter(x, y, color='b')
# 在点的旁边添加坐标值的注释
for i, txt in enumerate(x):
self.axes.annotate(txt, (x[i], y[i]), textcoords="offset points",
xytext=(7, -5), rotation=90, ha='center', va='center')
self.axes.annotate(y[i], (x[i], y[i]), textcoords="offset points",
xytext=(-10, 15), rotation=90, ha='center', va='center')
# 隐藏实际横坐标刻度
self.axes.set_xticks([])
self.axes.set_yticks([])
self.axes.set_ylim(-1, len(y) + 1)
self.axes.set_xlim(-1, len(x))
self.axes.set_title(title)
self.axes.set_xlabel(x_label)
self.axes.set_ylabel(y_label)
self.fig.canvas.draw()
module_logger.info("刷新画布")
# 柱形图
def create_bar(self, x, y, title, x_label="", y_label=""):
module_logger.info("开始创建柱状图")
self.axes.clear()
bars = self.axes.bar(x, y)
for bar, label in zip(bars, y):
bar.set_color(self._generate_random_color())
height = bar.get_height()
self.axes.text(bar.get_x() + bar.get_width() / 2, height / 2, label,
ha='center', va='center', rotation=90, color='w')
self.axes.set_yticks([])
self.axes.set_title(title)
self.axes.set_xlabel(x_label)
self.axes.set_ylabel(y_label)
self.fig.canvas.draw()
module_logger.info("刷新画布")
# 饼图
def create_pie(self, x, y, title, x_label="", y_label=""):
module_logger.info("开始创建饼图")
self.axes.clear()
self.axes.pie(x, labels=y, autopct='%6.1f%%')
self.axes.set_title(title)
self.axes.set_xlabel(x_label)
self.axes.set_ylabel(y_label)
self.fig.canvas.draw()
module_logger.info("刷新画布")
# 词云图
def create_clould(self, words, title, x_label="", y_label=""):
# 使用散点图实现简单的词云
module_logger.info("开始创建词云图")
self.axes.clear()
num_words = min(len(words), 40)
_x = np.random.rand(num_words)
_y = np.random.rand(num_words)
# 随机生成字体大小
sizes = np.random.randint(8, 30, num_words)
# 随机生成颜色
plt_colors = [self._generate_random_color() for _ in range(num_words)]
for i in range(num_words):
self.axes.text(_x[i], _y[i], words[i], fontsize=sizes[i],
color=plt_colors[i], ha='center', va='center')
# 关闭坐标轴
self.axes.axis('off')
self.axes.set_title(title)
self.axes.set_xlabel(x_label)
self.axes.set_ylabel(y_label)
self.fig.canvas.draw()
module_logger.info("刷新画布")
def graph_out(self):
"""
导出图像
@return:
"""
try:
self.fig.savefig(fname=self.config.STATS_IMG_SAVE_PATH
+ f"\\{str(self.name)}-{datetime.now().strftime('%H-%M-%S')}.png",
dpi=250)
module_logger.info(f"{self.name}图像导出至:{self.config.STATS_IMG_SAVE_PATH}")
except Exception as e:
module_logger.error(f"{self.name}图像导出错误:{e}")
def _generate_random_color(self):
r = random()
g = random()
b = random()
return (r, g, b)
# 清空图像
def clear(self):
self.axes.clear()
self.fig.canvas.draw()
使用方法非常简便,先创建一个图形实例,然后把它添加到你预设的位置,最后进行绘制图表,你可以多次绘制它,因为每次绘制都会进行一次清除然后再绘制:
# 221表示:两行两列,从第一行开始从左到右,依次排序的第一个位置
# 看到了吗,我们创建了一个叫做 fig_1 的图表
self.fig_221 = CustomFigure(name="fig_1")
# 在这里我们把图表 添加到布局里并放到预设好的
# self.figure_view_221 = self.figure_view_221 = QtWidgets.QWidget(self.widget_23) 里
fig_layout_221 = QtWidgets.QVBoxLayout()
fig_layout_221.addWidget(self.fig_221)
self.figure_view_221.setLayout(fig_layout_221)
# 调用业务层接口获取数据
(flag, (all_num, borrowed_num, remain_num)) = ServicesHelper.get_stats_service().get_book_total_case()
# 此时我们可以直接引用 CustomFigure 里的绘图函数直接绘制并显示图像了
self.fig_221.create_pie(x=[borrowed_num, remain_num],
y=["已出借", "未出借"],
title="图书馆出借数量情况"
)
# 如果你需要隐藏或显示,你应当操作他的容器
self.figure_view_221.show()
self.figure_view_221.hide()
图书列表详情
自定义的列表如何实现呢?这需要用到 QListWidget()。把自定义的组件 ui 绘制好,添加的时候逐个初始化并且填充到QListWidget 里面就可以了。
# book_detail.py 自定义 ui 组件
# 记得继承 Ui_BookDetail 获取他的属性
class BookDetail(QWidget, Ui_BookDetail):
"""
@ClassName:BookDetail
@Description:BookDetail 映射一本书的信息,用于查询界面创建列表单元,可以存在多个
负责一本书籍的借阅或归还操作,初始化时接收一个书籍类,以及角色
@Author:锦沐Python
"""
# 接收一个 Book() 实例,role 用于判断是否显示借阅按钮
def __init__(self, book: Book, role):
super().__init__()
# 设置UI
self.setupUi(self)
self.book = book
self.role = role
self.show_button_init()
# 显示图书详细信息
self.book_name_label.setText(f"{self.book.book_name}")
self.book_category_label.setText(f"分类:{self.book.category}")
self.book_id_label.setText(f"ID:{self.book.book_id}")
self.author_label.setText(f"作者:{self.book.author}")
self.publisher_label.setText(f"出版社:{self.book.publisher}")
self.record_time_label.setText(f"录入时间:{self.book.record_time}")
self.ISBN_label.setText(f"ISBN:{self.book.ISBN}")
self.publication_date_label.setText(f"出版时间:{self.book.publication_date}")
self.rent_status_label.setText("未出借" if self.book.borrowed_by=="" else "已出借")
self.loan_period_label.setText(f"可借天数:{self.book.loan_period}")
self.due_time_label.setText(f"归还日期:{self.book.due_time}")
self.borrowed_by_label.setText(f"借阅人ID:{self.book.borrowed_by}")
self.show_book_img()
def show_book_img(self):
pass
def show_button_init(self):
pass
def borrow_book_by_id(self):
pass
def back_book_by_id(self):
pass
使用自定义的列表组件:
# self.book_list_widget = QtWidgets.QListWidget(self.query_box_widget) 作为列表容器
def query_books(self):
"""
根据搜索的关键词,查找书籍并显示
:return:
"""
self.book_list_widget.clear()
key_words = self.query_search_keywords_edit.text()
(flag, books) = ServicesHelper.get_query_service().get_books_by_keywords(key_words)
if flag is False:
msg = books
self.messager.show_msg(msg, "error")
return
self.query_book_list = books
# 创建 QListWidgetItem 添加到 book_detail_widget 容器形成列表
if books:
for book in books:
# 看到了吗,我们实例化了 BookDetail(),并把它转为 QListWidgetItem 然后添加到了 book_list_widget
book_detail_widget = BookDetail(book, "user")
item = QListWidgetItem(self.book_list_widget)
item.setSizeHint(book_detail_widget.size())
self.book_list_widget.addItem(item)
self.book_list_widget.setItemWidget(item, book_detail_widget)
else:
self.messager.show_msg(f"抱歉,没有找到与{key_words}相关的书籍", "info")
![屏幕截图 2024-07-25 192951](./doc/屏幕截图 2024-07-25 192951.png)
图片显示
你可能看到了,每个图书组件居然可以显示独立的图片,如何实现呢?获取文件路径,加载文件,创建QPixmap对象,配置合适的大小,这里可能会降低图片的像素。最后插入到 self.img_label = self.img_label = QtWidgets.QLabel(self.widget_13) 里面即可。
def show_book_img(self):
try:
if self.book.img_url:
fileName = os.path.join(self.config.CURRENT_PATH, self.book.img_url)
pixmap = QPixmap(fileName)
pixmap = pixmap.scaled(self.img_label.size(), Qt.IgnoreAspectRatio)
self.img_label.setPixmap(pixmap)
except Exception as e:
module_logger.error(f"图片无法展示:{e}")
现在你可能会问,图片怎么保存的呢?弹出一个文件选择框,选择图片,然后获取他的路径,创建QPixmap对象,然后提供保存路径保存图片 pixmap.save(save_path)
def record_saveImage(self):
"""
添加图书界面_图片,将选择的图片插入页面,并回显路径名称
"""
try:
file_path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "*.jpg;;*.png")
if file_path == "":
self.messager.show_msg("图片未选择", "info")
return
# 显示图片
pixmap = QPixmap(file_path)
pixmap = pixmap.scaled(self.record_book_img.size(), Qt.IgnoreAspectRatio)
self.record_book_img.setPixmap(pixmap)
# 保存图片
# 使用时间戳作为图片名
img_name = f"{datetime.now().timestamp()}.png"
save_path = self.config.BOOK_IMG_SAVE_PATH + f"\\{img_name}"
pixmap.save(save_path)
# 显示保存路径
self.record_file_name.setText(save_path)
self.record_book.img_url = self.config.BOOK_IMG_SAVE_DIR + f"\\{img_name}"
self.messager.show_msg(f"上传图片成功", "info")
except Exception as e:
self.messager.show_msg(f"上传图片失败:{e}", "error")
module_logger.error(f"上传图片失败:{e}")
我是怎么知道每本书对应的图片的呢? 你可以在 db.json 里看到一本书的内容里有 img_url 字段,但他好像不是绝对路径啊,那我是如何访问图片的?为什么不保存绝对路径?
首先,不保存绝对路径,我的考虑:你的电脑文件夹一定和我的不一样,你我都不知道对方会把项目文件放到哪个文件夹里。如果保存绝对路径,只要你一搬动项目路径就无法访问图片。所以我使用路径拼接的方法,我唯一能确定的是,我的图片一定会保存到项目根目录下的 upload_imgs 文件夹里,而我要做的就是获取 img_url 之前的路径即可。
{
# 其他.....
"img_url": "upload_imgs\\1721902002.935139.png" # 图片路径
}
如何访问:先获取当前 main.py 运行的绝对路径,前面的配置类就完成了这个工作, 获取绝对路径后直接与 img_url 拼接即可,CURRENT_PATH + img_url = D:\A-XHSPro\PyQt5Pro\LibrarySystem\upload_imgs\1721902002.935139.png ,拿到地址就可以访问图片并把它绘制在界面上了。
# 获取绝对路径,相对于 main.py, 我的 main.py 在 D:\其他英文文件夹\LibrarySystem\main.py
# CURRENT_PATH = D:\其他英文文件夹\LibrarySystem
CURRENT_PATH = os.path.abspath('.')
BOOK_IMG_SAVE_DIR = "upload_imgs"
# 图书图片保存路径 D:\其他英文文件夹\upload_imgs
BOOK_IMG_SAVE_PATH = os.path.join(CURRENT_PATH, BOOK_IMG_SAVE_DIR)
# 如下:使用os.path.join更好些,因为可以自动化转换路径格式,兼容不同的系统。
fileName = os.path.join(self.config.CURRENT_PATH, self.book.img_url)
pixmap = QPixmap(fileName)
pixmap = pixmap.scaled(self.img_label.size(), Qt.IgnoreAspectRatio)
self.img_label.setPixmap(pixmap)
信号的传递与接收
什么是信号?Qt 的信号与槽机制是其核心特性之一,允许对象之间进行通信和交互,也就是在发送信号(携带参数或无参数),接收信号运行某个函数。本项目我们的信号一般用于页面跳转,我们以登陆界面跳转管理后台为例。为了集中管理信号,我们依然创建一个信号类。
class QSignals(QObject):
"""
@ClassName:QSignals
@Description:本项目只创建一个 QSignals 信号实例,
负责程序内所有信号,提供信号给需要的地方进行接收和发送
@Author:锦沐Python
"""
# 从登录界面跳转至管理界面
LoginToAdmin_Signal = pyqtSignal()
def __init__(self):
super().__init__()
# 直接创建一个实例在需要的地方引入即可
q_signals = QSignals()
我们现在要实现的功能是登录认证后跳转到管理页面
# LoginWidget 类
# 发送给MainWindow的跳转信号
q_signals.LoginToAdmin_Signal.emit()
# MainWindow类 里接收信号,因为我使用了 self.stackedWidget 来容纳登录界面和管理界面,所有跳转操作由 MainWindow 类进行处理。
# 连接跳转信号,用于页面跳转
q_signals.LoginToAdmin_Signal.connect(self.go_to_admin)
def go_to_admin(self):
# 登录成功后跳转并初始化管理界面
self.admin_widget = AdminWidget()
self.stackedWidget.addWidget(self.admin_widget)
self.stackedWidget.setCurrentIndex(1)
权限管理
该功能是我最后才写的,思路是将权限信息集中到一个类里,当登录后保存登录用户信息,然后根据权限信息判断来显示和隐藏或阻值某些程序运行。具体实现可能有些混乱,但勉强可以使用
class RoleManager:
"""
@ClassName:RoleManager
@Description: 角色管理器,本项目只有一个实例
负责保存 当前登录用户,当前权限,检查权限
@Author:锦沐Python
"""
# 权限编号
_AUTH_NO = {
"图书录入": 0,
"图书更新": 1,
"图书查询": 2,
"图书统计": 3,
"用户信息": 4,
}
# 角色列表
_ROLES = [
{"role": "admin", "position": "系统管理员", "permissions": [0, 1, 2, 3, 4]},
{"role": "user", "position": "普通操作员", "permissions": [2, 3, 4]}
]
def __init__(self):
self._current_role = None
self._current_permissions = None
self._login_user = None
def get_current_role_permissions(self):
if self._current_role is None or self._current_permissions is None:
module_logger.error("用户未登录,无法操作")
return (False, "用户未登录,无法操作")
return (True, self._current_permissions)
def set_current_role(self, user:User):
self._login_user = copy.deepcopy(user)
for roles in self._ROLES:
if self._login_user.role == roles["role"]:
self._current_role = roles["role"]
self._current_permissions = roles["permissions"]
module_logger.info(f"当前为 {self._login_user.role } 权限")
return (True, f"当前为 {self._login_user.role } 权限")
module_logger.error(f"不存在 {self._login_user.role } 权限")
return (False, f"不存在 {self._login_user.role } 权限")
def check_permissions(self, permission):
"""
检测权限是否存在用户权限
@param permission:
@return:
"""
if self._current_permissions is None:
return (False, "未登录")
flag = set(permission).issubset(self._current_permissions)
if flag:
return (True, "检查通过")
else:
return (False, "检查未通过")
def get_current_user(self):
if self._login_user is None:
return (False, "用户未登录")
return (True, self._login_user)
def get_current_role(self):
if self._current_role is None:
return (False, "用户未登录")
return (True, self._current_role)
def get_roles(self):
roles = set()
for role in self._ROLES:
roles.add(role["role"])
if roles:
return (True, roles)
return (False, "")
# 登录成功后我会执行下面代码
# 配置登录用户,用于权限控制
(flag, msg) = ServicesHelper.get_role_service().set_current_role(user)
if flag is False:
self.messager.show_error(msg)
return
# 然后在需要的地方,这段代码用于检查权限来隐藏管理界面的跳转按钮,按理来说应当删除,但为了简单就这样吧
(flag, login_user) = ServicesHelper.get_role_service().get_current_user()
if flag is False:
msg = login_user
self.messager.show_error(f"{msg},即将退出程序")
QTimer.singleShot(3000, QApplication.quit)
return
if ServicesHelper.get_role_service().check_permissions([0, 1, 2, 3, 4])[0]:
self.record_button.show()
self.update_button.show()
elif ServicesHelper.get_role_service().check_permissions([2, 3, 4])[0]:
self.record_button.hide()
self.update_button.hide()
# 具体内容你可以查看 role_service.py 文件
多线程处理
当我们只有几百条书籍或者用户数据时,GUI 程序 反应很快,我们察觉不到卡顿,但当书籍信息信息有上千条时,程序就无响应了,直接卡死,并且占用内存空间暴增。为什么会卡死界面呢?这是因为 GUI程序 其实就是一个 while 循环,他的循环速度非常快,他会以极短的时间扫描内部变量的状态,只要我们与程序交互都会让他内部变量变化从而自动实施对策。我们的代码都写在循环里,比如我们点击搜索按钮时,就会执行查询操作,然而查询操作非常耗时,需要数秒,在这段查询时间里,while 循环内部一直在等待查询操作执行完毕,所以阻碍了状态扫描,无法在查询时间段里响应我们的其他操作,比如移动窗口,最小化等。
如何解决卡顿问题呢?PyQt5 已经为我们准备了多线程处理类,我们只需要正确的使用它即可。
- 创建一个工作 Worker 类,继承 QObject ,用于编写耗时操作代码
- 在工作类 里创建 信号,用于通知主程序线程运行完毕,并将结果发送给信号接收方
- 创建 QThread 实例,以及 Worker 实例,将 Worker 实例添加到 QThread 内部进行管理
- 连接 耗时函数,连接完成信号槽函数
我们以统计书籍的借阅信息作为示例:
创建工作类,由于连接函数时不好传递参数,我们可以初始化 Worker 时传递 keywords
# stats_service.py
class Worker(QObject):
"""
@ClassName:Worker
@Description:
负责实现 图书信息统计算法,并返回结果,包括借阅情况,图书种类,出版时间等信息
@Author:锦沐Python
"""
# 两个信号一般起发送
# 结束运算信号,用于通知 StatsService 反转线程标志
finally_signal = pyqtSignal()
# 数据传输信号,将结果传递给视图层 定义的槽函数 accept_data_func
data_signal = pyqtSignal(tuple)
def __init__(self, keywords=""):
super().__init__()
self.keywords = keywords
def calculate_borrowing_and_match_case_by_keywords(self):
"""
根据关键词查找书籍,统计书籍借阅情况,以及匹配的图书名称,最大 10 个参数
:return: (all_num, borrowed_num, book_name_list)
"""
module_logger.info("线程开始计算")
# 从数据层读取所有的书籍信息 5000 条
(flag, book_list) = RepositoryHelper.get_book_repository().get_books(-1)
# 失败就终止
if flag is False:
msg = book_list
self.data_signal.emit((False, msg))
self.finally_signal.emit()
module_logger.info("线程无法获取图书信息")
return
# 下面就是进行 字符串匹配操作
# 使用 any(self.keywords in getattr(book, field, "") for field in fields_to_check)
book_name_list = []
borrowed_num = 0
all_num = 0
# 定义一个包含需要检查字段的集合
fields_to_check = ["book_name", "book_id", "category", "author"]
# 遍历书籍信息
for book in book_list:
if any(self.keywords in getattr(book, field, "") for field in fields_to_check):
all_num += 1
if book.borrowed_by:
borrowed_num += 1
# 收集相关词
if len(book_name_list) < 10:
book_name_list.append(book.book_name)
# 处理完成后发送信号
self.data_signal.emit((True, (all_num, borrowed_num, book_name_list)))
self.finally_signal.emit()
module_logger.info("关键词统计完成计算")
然后在服务层使用,为了方便管理,其实 Worker 类和 StatsService 在同一个文件里
# stats_service.py
class StatsService(StatsServiceInterface):
"""
@ClassName:StatsService
@Description: 继承 统计业务层接口类,本项目只创建一个实例
负责实现启动线程进行 图书信息统计,包括借阅情况,图书种类,出版时间等信息
--------------------------------------------------
此类编写请注意,不要将:
self.worker_1 = Worker(keywords=keywords)
self.thread_1 = QThread()
提升到 __init__() 进行初始化,他不会工作的
也不要不添加 self,下面的也不会工作:
--------------------------------------------------
创建实例
worker_1 = Worker(keywords=keywords)
thread_1 = QThread()
将 Worker 加入 QThread 管理
worker_1.moveToThread(self.thread_1)
--------------------------------------------------
@Author:锦沐Python
"""
def __init__(self):
# 线程运行标识,阻止短时间内多次触发和创建线程,为 True 时允许启动线程
self.calculate_1_tag = True
# 视图层 admin_widget 调用了该函数
def calculate_match_case_by_keywords_thresd(self, accept_data_func, keywords):
"""
根据关键词查找书籍,统计借阅信息,与关键词相关的图书名称,限制最大数量为 10个
@param accept_data_func: 槽函数引用
@param keywords: 关键词
:信号发送: (all_num, borrowed_num, book_name_list)
"""
if self.calculate_1_tag:
module_logger.info("线程1启动")
self.calculate_1_tag = False
# 创建实例
self.worker_1 = Worker(keywords=keywords)
self.thread_1 = QThread()
# 将 Worker 加入 QThread 管理
self.worker_1.moveToThread(self.thread_1)
# 连接耗时操作 worker_1.calculate_borrowing_and_match_case_by_keywords 槽函数
self.thread_1.started.connect(self.worker_1.calculate_borrowing_and_match_case_by_keywords)
# 接收数据信号 槽函数 accept_data_func,他是 视图层传递的 槽函数
self.worker_1.data_signal.connect(accept_data_func)
# 线程结束信号 槽函数 _set_calculate_tag
self.worker_1.finally_signal.connect(self._set_calculate_tag)
self.thread_1.start()
else:
module_logger.info("线程1锁定,稍后再试")
服务层写好了,我们就要在视图层使用了。我们的目的是获取结果,把他们绘制到图表上进行展示。
# admin_widget.py
def stats_search(self):
"""
有关关键词的书籍借阅情况
匹配书籍的书名,最多10个
"""
#将图像放置为上下俩个
self.figure_view_221.show()
self.figure_view_222.hide()
self.figure_view_223.show()
self.figure_view_224.hide()
# 获取输入的关键词
key_words = self.stats_keywords_edit.text()
if len(key_words) < 2:
module_logger.info("关键词长度必须大等于2")
self.messager.show_msg(f"关键词长度必须大等于2", "info")
return
# 此函数就是槽函数,当我们的线程结束后传递参数的信号接收方就会执行它
def get_data_func(data):
"""
作为槽函数 传递给线程
@param data:
"""
module_logger.info("开始绘制")
(flag, (all_num, borrowed_num, book_name_list)) = data
if flag:
# 绘制两张图
self.fig_221.create_pie(x=[all_num - borrowed_num, borrowed_num],
y=["未出借","已出借"],title="借阅情况")
self.fig_223.create_clould(words=book_name_list,title="相关书籍名称")
# 从这里启动线程,看到了吗,我们把key_words传递给了 calculate_match_case_by_keywords_thresd 函数
ServicesHelper.get_stats_service().calculate_match_case_by_keywords_thresd(accept_data_func=get_data_func,
keywords=key_words)
无边框模式
无边框意味着去除原始的窗口边框样式,自定义标题栏。你需要完成鼠标事件的重写,包括窗口移动,窗口缩放,最小化,最大化关闭程序。目前为止我已完成窗口移动,最小化,最大化关闭程序。窗口缩放不稳定,我还没找到鼠标无法替换的原因,这也是本项目最大的遗憾。如果你对无边框感兴趣,可以重点参考两个文件即可: main_window.py, title_bar.py。部分关键代码如下:
# main_window.py
# 设置背景透明
self.setAttribute(Qt.WA_TranslucentBackground, True)
# 设置窗体无边框, 保留状态栏大小化功能
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowSystemMenuHint
| Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint)
# 定义边缘用于触发伸缩大小
self._edge_margins = 5
# 伸缩方位
self.Direction = None
# 左键按下状态
self._pressed = False
# 移动
self.move_drag = False
# 安装事件过滤器
self.installEventFilter(self)
# 递归设置所有子控件的鼠标跟踪
self.setMouseTrackingRecursive(self, True)
"""
以下代码仅用于无边框模式,用于移动,缩放窗口
"""
def setMouseTrackingRecursive(self, widget, enable):
"""
用于迭代子组件,配置数鼠标跟踪模式
@param widget: 根容器
@param enable: 追踪开关
"""
widget.setMouseTracking(enable)
for child in widget.findChildren(QWidget):
self.setMouseTrackingRecursive(child, enable)
def move(self, pos):
"""
重写父类方法,最大化或者全屏则不允许移动
@param pos:
@return:
"""
if self.windowState() == Qt.WindowMaximized or self.windowState() == Qt.WindowFullScreen:
return
super(MainWindow, self).move(pos)
def eventFilter(self, obj, event):
"""
父类方法重写
@param obj:
@param event:
@return:
"""
if event.type() == QEvent.Enter or event.type() == QEvent.Leave:
self.setCursor(Qt.ArrowCursor)
return True
return super().eventFilter(obj, event)
def paintEvent(self, event):
"""
父类方法重写 由于是全透明背景窗口,重绘事件中绘制透明度为1的边框,用于检测边缘
@param event:
@return:
"""
super(MainWindow, self).paintEvent(event)
painter = QPainter(self)
painter.setPen(QPen(QColor(255, 255, 255, 1), 2 * self._edge_margins))
painter.drawRect(self.rect())
def mousePressEvent(self, event):
"""鼠标点击事件"""
super(MainWindow, self).mousePressEvent(event)
if event.button() == Qt.LeftButton:
self._mpos = event.pos()
self._pressed = True
def mouseReleaseEvent(self, event):
'''鼠标弹起事件'''
super(MainWindow, self).mouseReleaseEvent(event)
self._pressed = False
self.Direction = None
self.setCursor(Qt.ArrowCursor)
def mouseMoveEvent(self, event):
"""鼠标移动事件"""
self.setCursor(Qt.ArrowCursor)
super(MainWindow, self).mouseMoveEvent(event)
pos = event.pos()
xPos, yPos = pos.x(), pos.y()
wm, hm = self.width() - 2 * self._edge_margins, self.height() - 2 * self._edge_margins
if self.isMaximized() or self.isFullScreen():
self.Direction = None
self.setCursor(Qt.ArrowCursor)
return
if event.buttons() == Qt.LeftButton and self._pressed:
self._resizeWidget(pos)
return
if xPos <= self._edge_margins and yPos <= self._edge_margins:
# 左上角
self.Direction = LeftTop
self.setCursor(Qt.SizeFDiagCursor)
elif wm <= xPos <= self.width() and hm <= yPos <= self.height():
# 右下角
self.Direction = RightBottom
self.setCursor(Qt.SizeFDiagCursor)
elif wm <= xPos and yPos <= self._edge_margins:
# 右上角
self.Direction = RightTop
self.setCursor(Qt.SizeBDiagCursor)
elif xPos <= self._edge_margins and hm <= yPos:
# 左下角
self.Direction = LeftBottom
self.setCursor(Qt.SizeBDiagCursor)
elif 0 <= xPos <= self._edge_margins and self._edge_margins <= yPos <= hm:
# 左边
self.Direction = Left
self.setCursor(Qt.SizeHorCursor)
elif wm <= xPos <= self.width() and self._edge_margins <= yPos <= hm:
# 右边
self.Direction = Right
self.setCursor(Qt.SizeHorCursor)
elif self._edge_margins <= xPos <= wm and 0 <= yPos <= self._edge_margins:
# 上面
self.Direction = Top
self.setCursor(Qt.SizeVerCursor)
elif self._edge_margins <= xPos <= wm and hm <= yPos <= self.height():
# 下面
self.Direction = Bottom
self.setCursor(Qt.SizeVerCursor)
def _resizeWidget(self, pos):
"""调整窗口大小"""
if self.Direction is None:
return
mpos = pos - self._mpos
xPos, yPos = mpos.x(), mpos.y()
geometry = self.geometry()
x, y, w, h = geometry.x(), geometry.y(), geometry.width(), geometry.height()
if self.Direction == LeftTop: # 左上角
if w - xPos > self.minimumWidth():
x += xPos
w -= xPos
if h - yPos > self.minimumHeight():
y += yPos
h -= yPos
elif self.Direction == RightBottom: # 右下角
if w + xPos > self.minimumWidth():
w += xPos
self._mpos = pos
if h + yPos > self.minimumHeight():
h += yPos
self._mpos = pos
elif self.Direction == RightTop: # 右上角
if h - yPos > self.minimumHeight():
y += yPos
h -= yPos
if w + xPos > self.minimumWidth():
w += xPos
self._mpos.setX(pos.x())
elif self.Direction == LeftBottom: # 左下角
if w - xPos > self.minimumWidth():
x += xPos
w -= xPos
if h + yPos > self.minimumHeight():
h += yPos
self._mpos.setY(pos.y())
elif self.Direction == Left: # 左边
if w - xPos > self.minimumWidth():
x += xPos
w -= xPos
else:
return
elif self.Direction == Right: # 右边
if w + xPos > self.minimumWidth():
w += xPos
self._mpos = pos
else:
return
elif self.Direction == Top: # 上面
if h - yPos > self.minimumHeight():
y += yPos
h -= yPos
else:
return
elif self.Direction == Bottom: # 下面
if h + yPos > self.minimumHeight():
h += yPos
self._mpos = pos
else:
return
self.setGeometry(x, y, w, h)
# 创建时将主窗口传递
self.title_bar = TitleBar(self)
为了好封装组件,我把主窗口传递给了 TitleBar
# title_bar.py
# -*- coding:utf-8 -*-
"""
无边框模式下的自定义标题栏
"""
from PyQt5.QtCore import Qt, QEvent
from PyQt5.QtWidgets import QWidget
from LibrarySystem.views.ui.TitleBar import Ui_TitleBar
class TitleBar(QWidget, Ui_TitleBar):
"""
@ClassName:TitleBar
@Description: 本项目 TitleBar 只有一个实例
负责无边框模式下,创建自定义标题栏,
必须接收主窗口实例,用于控制窗体位置移动
@Author:锦沐Python
"""
# 接收父窗口
def __init__(self, parent_window=None):
super().__init__()
self.setupUi(self)
self.parent_window = parent_window
# 设置鼠标跟踪判断扳机默认值
self.parent_window.move_drag = False
# 最小化
self.min_button.clicked.connect(lambda: self.parent_window.showMinimized())
# 最大化
self.max_button.clicked.connect(lambda: self.parent_window.showNormal()
if self.parent_window.isMaximized()
else self.parent_window.showMaximized()
)
# 关闭程序
self.close_button.clicked.connect(lambda: self.parent_window.close())
def move(self, pos):
super(TitleBar, self.parent_window).move(pos)
def eventFilter(self, obj, event):
if event.type() == QEvent.Enter or event.type() == QEvent.Leave:
self.setCursor(Qt.ArrowCursor)
return True
return super().eventFilter(obj, event)
def mousePressEvent(self, event):
# 重写鼠标点击的事件
if event.button() == Qt.LeftButton:
if event.y() < self.TopBar_widget.height():
# 鼠标左键点击标题栏区域
self.parent_window.move_drag = True
self.parent_window.move_DragPosition = event.globalPos() - self.parent_window.pos()
event.accept()
def mouseMoveEvent(self, event):
if event.buttons() & Qt.LeftButton:
# 处理按下左键后的拖动或调整大小操作
if self.parent_window.move_drag:
self.parent_window.move(event.globalPos() - self.parent_window.move_DragPosition)
event.accept()
else:
self.setCursor(Qt.ArrowCursor)
def mouseReleaseEvent(self, QMouseEvent):
# 鼠标释放后,各扳机复位
self.parent_window.move_drag = False
软件打包为(Windows 带独立文件夹) exe
- 独立性:用户无需安装额外的解释器或运行时环境,因为所有必要的组件都被打包进了一个可执行文件中
- 快速启动:与解释型语言相比,编译后的可执行文件通常启动速度更快,因为它们直接由操作系统执行而不需要动态解释代码。
- 独立性和稳定性:避免了依赖环境不一致导致的兼容性问题,提升了软件的稳定性和可靠性。
- 下载依赖包 pyinstaller
pip install pyinstaller
- 生产配置文件
pyinstaller --onefile --saveopts main.spec main.py
- 配置 main.spec
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[ ],
binaries=[],
datas=[
(r"repositories\json\data.json", r"repositories\json"), #(源文件路径,目标文件路径文件夹)
(r"views\fonts\Alibaba-PuHuiTi-Regular.ttf", r"views\fonts")],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='图书管理系统',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=[r'views\rcc\icon.png'], #图标路径
onefile=True
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True, # 开启 upx 压缩功能
upx_exclude=["python3.dll"], # 忽略文件
name='main',
)
- 执行打包
pyinstaller main.spec
- 打包内容会保存在根目录下的 dist 文件内
打包压缩 upx
直接使用 pyinstaller 打包后的文件可能会很大,该项目原始 100 多MB, 使用 upx 后减少了将近一半的存储。
只需要把 upx.exe 复制到 pyinstaller.exe 相同文件夹即可,因为我使用的是虚拟环境,所以 pyinstaller.exe 在 venv/Script/ 内,
然后再次打包即可。
包装为安装器
- 下载 Inno Setup Inno Setup下载_Inno Setup(安装制作工具)绿色中文版下载6.2.0 - 系统之家 (xitongzhijia.net)
- 安装完成后可以观看配套的打包教程 : Inno Setup 打包教程.mp4
源码请私信获取