找到
27
篇与
技术干货
相关的结果
-
晓翼引擎ai大模型中转 新一代api大模型中转,tokens价格比官网更便宜,充值比例0.8:1,充值0.8有1元额度,模型丰富覆盖大部分模型,稳定可靠 ✅ 支持模型: • 只需一个账号,即可畅享100+前沿大模型,支持Gemini、Claude、Deepseek、Doubao、Jimeng、Qwen、Glm、Kimi、Minimax等 ✅ 服务优势: • 官方源头,稳定供应,价格低廉 • 全天可用,随时调用,按量调用 • 按需调用,按量计费,明细可查 IMG_20260126_121940.png图片 IMG_20260126_122003.png图片 http://api.ziyeyao.com -
PyQt5程序打包发布:PyInstaller从入门到精通(exe单文件+避坑指南) 第18篇:PyQt5程序打包发布:从代码到exe可执行文件(全程避坑指南) 哈喽~ 欢迎来到PyQt5系列的第18篇!前面我们已经开发出了功能完整、界面美观、支持数据库持久化的多线程下载工具,但目前只能在Python环境中运行(需要安装PyQt5、requests等库)。想要把程序分发给普通用户(无需安装Python和任何依赖),就必须掌握 PyQt5程序打包发布 技术! mknfu5eq.png图片 今天我们就来学习最主流的打包工具 PyInstaller 的使用方法,手把手教你将下载工具打包成 Windows可执行文件(exe),同时解决打包过程中的各种坑(路径错误、资源丢失、体积过大、杀毒误报等),让你的程序可以直接分发给用户运行! 一、打包工具选择:为什么选PyInstaller? 在Python打包工具中,PyInstaller是最适合PyQt5程序的,原因如下: 跨平台支持:支持Windows(exe)、macOS(app)、Linux(可执行文件); 简单易用:一行命令即可完成打包,无需复杂配置; 深度兼容PyQt5:自动识别PyQt5的依赖库和资源文件,无需手动指定; 灵活定制:支持单文件/多文件打包、设置图标、隐藏控制台、添加版本信息等。 其他工具对比: cx_Freeze:兼容性较好,但配置繁琐,需编写setup.py脚本; py2exe:仅支持Windows,且对Python新版本兼容性差; nuitka:将Python代码编译为C语言,体积更小、运行更快,但学习成本高。 综上,PyInstaller是新手的最佳选择! 二、PyInstaller基础用法:从安装到打包 1. 安装PyInstaller 打开命令提示符(CMD)或终端,执行以下命令: pip install pyinstaller验证安装成功: pyinstaller --version出现版本号(如6.3.0)则表示安装成功。 2. 核心打包命令与参数 PyInstaller的核心命令格式: pyinstaller [参数] 你的脚本名.py常用参数说明(必记!) 参数作用示例-F/--onefile打包为单个exe文件(方便分发,启动稍慢)pyinstaller -F main.py-D/--onedir打包为多文件目录(启动快,包含多个依赖文件)pyinstaller -D main.py-w/--windowed隐藏控制台窗口(GUI程序必备,否则运行时会弹出黑窗口)pyinstaller -F -w main.py-i/--icon设置程序图标(支持.ico格式,不支持png/jpg)pyinstaller -F -w -i icon.ico main.py--add-data添加外部资源文件(如数据库、QSS、图片等)Windows:--add-data "db.db;." macOS/Linux:--add-data "db.db:."--name指定生成的exe文件名pyinstaller -F -w --name 下载工具 main.py-c/--console显示控制台窗口(用于调试,查看报错信息)pyinstaller -F -c main.py单文件 vs 多文件打包对比 打包方式优点缺点适用场景单文件(-F)只有一个exe,方便用户下载使用启动速度慢,运行时会解压到临时目录小型工具、需要快速分发多文件(-D)启动速度快,可直接修改资源文件生成一个目录,包含多个文件大型程序、需要频繁更新资源三、实战:打包多线程下载工具(带数据库+QSS) 我们以第17篇的 带数据库持久化的多线程下载工具 为例,演示完整打包流程。 步骤1:准备工作(打包前必做!) 整理项目文件:将所有相关文件放在同一个文件夹中,结构如下: 下载工具/ ├─ main.py (主程序脚本) ├─ download_history.db (数据库文件) ├─ icon.ico (程序图标,可选) └─ README.txt (使用说明,可选)注意:图标必须是 ico格式,如果只有png图片,可以用在线工具(如ConvertICO)转换。 测试脚本运行:确保在Python环境中能正常运行main.py,避免因代码错误导致打包失败。 步骤2:解决资源路径问题(最容易踩坑!) 打包后最常见的问题是 资源文件找不到(如数据库、QSS文件),原因是: 单文件打包时,程序运行会将exe解压到系统临时目录(C:\Users\用户名\AppData\Local\Temp\_MEIxxxxxx); 代码中使用的相对路径在打包后会失效,需要动态获取资源的真实路径。 解决方案:编写路径获取函数 在main.py中添加以下函数,用于获取打包后的资源路径: import sys import os def get_resource_path(relative_path): """ 获取打包后的资源文件路径 :param relative_path: 资源文件的相对路径 :return: 资源文件的绝对路径 """ if hasattr(sys, '_MEIPASS'): # 打包后,_MEIPASS指向临时解压目录 base_path = sys._MEIPASS else: # 开发环境,指向当前脚本目录 base_path = os.path.abspath(".") return os.path.join(base_path, relative_path)修改代码中的资源路径 将原来直接使用相对路径的地方,替换为get_resource_path函数: 数据库路径修改: # 原代码 self.db = DBManager("download_history.db") # 修改后 db_path = get_resource_path("download_history.db") self.db = DBManager(db_path) 如果有QSS文件(外部样式表): # 原代码 with open("style.qss", "r", encoding="utf-8") as f: qss = f.read() # 修改后 qss_path = get_resource_path("style.qss") with open(qss_path, "r", encoding="utf-8") as f: qss = f.read() 步骤3:执行打包命令 打开CMD,切换到项目文件夹目录(如cd D:\下载工具),执行以下命令: 方案1:打包为单文件(推荐分发) pyinstaller -F -w -i icon.ico --add-data "download_history.db;." --name 多线程下载工具 main.py -F:单文件打包; -w:隐藏控制台; -i icon.ico:设置图标; --add-data "download_history.db;.":将数据库文件添加到打包资源中(Windows用;分隔,macOS/Linux用:); --name 多线程下载工具:指定exe文件名为“多线程下载工具.exe”。 方案2:打包为多文件(推荐调试) pyinstaller -D -w -i icon.ico --add-data "download_history.db;." --name 多线程下载工具 main.py步骤4:查看打包结果 执行命令后,PyInstaller会在项目文件夹中生成3个目录/文件: build/:临时编译目录,可删除; dist/:最终打包结果,单文件打包会生成exe,多文件打包会生成一个目录; xxx.spec:打包配置文件(可修改后二次打包)。 打包成功后,dist文件夹中的多线程下载工具.exe(单文件)或多线程下载工具目录(多文件)就是可直接运行的程序! 四、打包常见问题与避坑指南(解决90%的问题) 打包过程中会遇到各种问题,以下是最常见的坑及解决方案: 问题1:打包后运行exe提示“找不到模块”(如No module named 'PyQt5') 原因:PyInstaller未识别到某些依赖模块; 解决方案: 确保已安装所有依赖库(pip install pyqt5 requests); 用--hidden-import参数手动指定缺失的模块,例如: pyinstaller -F -w --hidden-import PyQt5.QtWidgets --hidden-import requests main.py 问题2:运行exe时弹出黑窗口(即使加了-w参数) 原因:代码中使用了print()语句或有控制台输出; 解决方案: 注释掉代码中的所有print()语句; 确保-w参数正确添加,命令示例:pyinstaller -F -w main.py。 问题3:资源文件找不到(数据库、QSS、图片等) 原因:未使用get_resource_path函数,相对路径失效; 解决方案: 按照步骤2的方法,添加get_resource_path函数; 用--add-data参数正确添加资源文件; 单文件打包时,运行后可在任务管理器查看临时目录(_MEIxxxxxx),检查资源是否被解压。 问题4:打包后的exe体积过大(几百MB) 原因:PyInstaller会打包所有依赖库,包括Python解释器; 优化方案: 使用虚拟环境:创建干净的虚拟环境,只安装必要的库(PyQt5、requests),避免打包多余依赖; 删除无用模块:用--exclude-module参数排除不需要的模块,例如: pyinstaller -F -w --exclude-module tkinter --exclude-module test main.py 使用UPX压缩:下载UPX工具,用--upx-dir参数指定UPX路径,压缩exe体积(需自行下载UPX)。 问题5:杀毒软件误报病毒 原因:PyInstaller打包的exe会被部分杀毒软件误判为病毒(因为是未知程序); 解决方案: 将exe文件添加到杀毒软件的信任列表; 用--name参数设置合理的程序名,避免使用敏感词汇; 发布时提供程序的MD5校验值,证明文件未被篡改。 问题6:运行exe时提示“Failed to execute script main” 原因:代码有错误,或依赖库缺失; 解决方案: 去掉-w参数,用-c参数打包(显示控制台),运行exe查看具体报错信息; 根据报错信息修复代码(如缺少模块、路径错误等)。 四、进阶优化:定制打包配置(修改spec文件) PyInstaller打包时会生成一个 .spec文件(如main.spec),这是打包的配置文件,可直接修改它实现更灵活的打包。 什么时候需要修改spec文件? 需要添加多个资源文件; 需要设置程序的版本信息、公司名称等; 需要自定义打包逻辑(如压缩、加密)。 示例:修改spec文件添加资源 打开生成的main.spec文件,找到datas参数,添加需要打包的资源: # main.spec a = Analysis( ['main.py'], pathex=[], binaries=[], # 添加资源文件:格式为 (源文件路径, 目标路径) datas=[('download_history.db', '.'), ('style.qss', '.'), ('icon.ico', '.')], hiddenimports=['requests', 'PyQt5.QtWidgets'], hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=None) exe = EXE( pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='多线程下载工具', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=False, # 等同于 -w 参数,False=隐藏控制台 disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon='icon.ico' # 设置图标 )修改完成后,直接用spec文件打包: pyinstaller main.spec五、多平台打包说明 1. Windows打包注意事项 图标必须是 ico格式,分辨率建议为64x64或128x128; 打包时使用的Python版本最好与用户的一致(32位/64位); 避免使用系统盘根目录打包,防止权限不足。 2. macOS打包注意事项 需要在macOS系统上打包,无法在Windows上生成dmg文件; 打包命令:pyinstaller -F -w -i icon.icns main.py(图标为icns格式); 打包后需要签名才能在其他macOS设备上运行。 3. Linux打包注意事项 打包命令:pyinstaller -F -c main.py(Linux一般显示控制台); 生成的可执行文件需要设置执行权限:chmod +x main; 依赖系统的libc库,建议在低版本Linux(如Ubuntu 18.04)上打包,提高兼容性。 六、发布程序:给用户的最终版本 打包完成后,建议做以下工作,提升用户体验: 压缩文件:将exe文件或多文件目录压缩为zip包,方便用户下载; 编写使用说明:包含程序功能、运行方法、常见问题解决; 提供更新日志:记录版本更新内容; 测试兼容性:在不同版本的Windows(如Win7、Win10、Win11)上测试运行。 总结 打包核心流程:整理项目文件 → 解决路径问题 → 执行打包命令 → 测试exe → 优化发布; 必记参数:-F(单文件)、-w(隐藏控制台)、-i(图标)、--add-data(添加资源); 避坑关键:使用get_resource_path获取资源路径,用-c参数调试报错,虚拟环境减小体积; 进阶技巧:修改spec文件定制打包配置,添加版本信息,使用UPX压缩。 至此,我们的PyQt5系列教程就全部结束了!从基础控件到复杂实战,从界面美化到数据库交互,再到最终打包发布,你已经掌握了开发一个完整桌面应用的所有技能。现在,你可以动手开发自己的PyQt5程序了——比如记事本、音乐播放器、数据管理工具等! -
2026最新 OpenSpeedy百度网盘加速教程 免费突破限速100KB/s变70MB/s 2026最新!OpenSpeedy百度网盘免费加速教程:100KB/s秒变70MB/s,安全无风险 百度网盘免费用户限速痛点谁懂啊!动辄100KB/s以内的下载速度,下一个几GB的文件要等十几个小时,开通SVIP又觉得性价比不高。今天给大家分享一款亲测有效的免费加速工具——OpenSpeedy,原本是用于突破游戏帧率限制的进程变速工具,2026年实测发现对百度网盘限速有奇效,无需修改系统内核,不用复杂配置,解压就能用,轻松把下载速度从100KB/s提升到2-3MB/s,网络环境好的情况下最高可达70MB/s,彻底告别漫长等待! mkj8mp4s.png图片 一、工具核心解析:开源安全,专为加速而生 1. 工具基本信息 OpenSpeedy是一款完全开源免费的Windows平台工具,遵循GNU v3开源许可证,代码托管在GitHub上,所有功能透明可查,不存在恶意插件或隐藏捆绑。它的核心原理是通过Ring3层Hook技术修改系统时间函数,在用户层实现进程变速,全程不侵入系统内核,不会对电脑硬件或系统稳定性造成影响,相比其他破解类加速工具,安全性大幅提升。 2. 核心特性:轻量便捷,兼容性拉满 双架构全面兼容:同时支持x86与x64架构的进程,不管你的电脑是32位还是64位系统,都能正常使用,无需担心兼容性问题; 安全无风险操作:采用用户层Hook技术,不修改系统内核文件,不篡改百度网盘客户端程序,使用后不会导致系统蓝屏、崩溃,也不会触发杀毒软件报毒(部分杀软可能误报,可添加信任); 轻量免安装设计:整个工具压缩包仅10MB大小,解压后即可直接运行,无需安装复杂的依赖组件,用完随时删除,不占用电脑额外空间; 开源透明可追溯:所有源代码在GitHub公开,可自行查看代码逻辑,确认无后门、无恶意功能,使用更放心,技术爱好者还能基于源码二次开发。 3. 加速效果实测 免费用户初始速度:100KB/s左右(常规限速水平); 加速后稳定速度:2-3MB/s(大多数网络环境下的实际速度); 极限加速速度:70MB/s(光纤网络+服务器节点优质时可达); 注意:百度网盘客户端界面显示的速度可能不会变化,但通过任务管理器、火绒等工具监控的实际下载速度会明显提升,属于“界面显示不变,实际速度翻倍”的情况。 二、前期准备:工具下载与环境确认 1. 工具下载渠道 下载 下载地址:https://pan.quark.cn/s/f495b0b1d3b1 提取码: 工具解压后文件清单:OpenSpeedy.exe(主程序)、bridge32.exe(32位注入器)、bridge64.exe(64位注入器)、config.ini(配置文件)、speedpatch32.dll(32位补丁)、speedpatch64.dll(64位补丁),缺少任意文件会导致工具无法正常运行。 2. 运行环境要求 操作系统:仅支持Windows 8及以上系统(Win7及以下系统不兼容,强行运行可能导致蓝屏); 网络环境:无特殊要求,宽带、光纤、校园网均可(网络带宽越高,加速后的上限速度越高); 百度网盘版本:必须使用最新版客户端(旧版本可能存在进程结构变化,导致加速失效,建议先在官网更新至最新版); 权限要求:无需管理员权限即可运行(部分电脑因安全设置限制,可尝试以管理员权限启动)。 三、详细操作步骤:4步搞定百度网盘加速 1. 工具准备:解压与启动 下载工具压缩包后,右键解压至任意目录(强烈建议选择英文路径,如“D:\Tools\OpenSpeedy”,中文路径可能导致进程注入失败或乱码); 打开解压后的文件夹,双击“OpenSpeedy.exe”运行程序,首次启动会自动加载系统进程列表,默认变速倍率为100倍(此倍率过高,后续需调整,切勿直接使用); 启动后界面会显示操作系统信息、CPU/内存占用、32/64位注入器状态(显示“正常”即为可用),以及快捷键说明(后续调整倍率会用到)。 mkj8cfjk.png图片 2. 百度网盘配置:登录与文件准备 打开百度网盘客户端,使用账号登录(建议使用非主力账号,降低账号风险,后续会详细说明风险点); 在网盘内找到需要下载的目标文件,导航至文件所在目录,确认文件可下载,但不要点击“开始下载”(需先设置加速,再启动下载); 检查网盘客户端是否为最新版:点击客户端右上角“设置”→“关于”,查看版本号,若不是最新版,点击“检查更新”完成升级后重启客户端。 3. 进程加速设置:选中+调倍率 返回OpenSpeedy工具界面,在进程搜索框中输入“baidu”,筛选出所有百度网盘相关进程(核心进程包括:BaiduNetdisk.exe、baidunetdiskhost.exe、BaiduNetdiskUnite.exe,需全部勾选,遗漏任意一个都会导致加速失效); 确认所有百度相关进程都已勾选后,记住快捷键:Ctrl+Alt+↑(增加速度倍率)、Ctrl+Alt+↓(减少速度倍率)、Ctrl+Alt+0(重置为原速); 初始默认倍率为100倍,需先通过快捷键将倍率调整至10倍左右(后续可根据实际情况微调),切勿直接使用100倍倍率,容易导致下载错误、进程崩溃。 mkj8cr6b.png图片 4. 启动下载:监控与微调 切换回百度网盘客户端,点击目标文件的“下载”按钮,开始下载任务; 立即切换回OpenSpeedy工具,根据实际下载情况调整倍率: 若速度较慢(低于1MB/s):按Ctrl+Alt+↑,逐步将倍率提升至12-15倍(每次提升2倍,观察30秒再调整); 若出现下载卡顿、进度不动:按Ctrl+Alt+↓,将倍率降低至8-10倍,等待1分钟后观察是否恢复; 若提示下载错误:按Ctrl+Alt+0重置为原速,暂停下载后重新开始,再将倍率调整至8倍以下尝试; 打开任务管理器(Ctrl+Shift+Esc),切换到“性能”→“以太网”(或WLAN),实时监控“发送/接收”速度,确认实际下载速度是否提升(百度网盘界面显示的速度仅作参考,以任务管理器为准)。 mkj8d1jz.png图片 四、最佳实践与注意事项:避免踩坑,稳定加速 1. 加速倍率最佳区间 推荐倍率:10-15倍(经过大量实测,这个区间既能保证加速效果,又能避免下载错误、进程崩溃,稳定性最高); 谨慎倍率:16-20倍(网络环境好、电脑配置高时可尝试,但若出现卡顿立即降低); 禁止倍率:20倍以上(极高概率导致百度网盘进程崩溃、下载文件损坏,甚至被网盘系统检测到异常)。 2. 下载操作规范 单次下载文件大小:建议不超过20GB,超过20GB的大文件建议分卷下载(百度网盘对大文件的限速策略更严格,分卷后加速效果更稳定,也能避免因单次下载时间过长导致的连接中断); 多文件下载:同时下载的文件数量不超过3个,多文件并行下载会分散带宽,导致每个文件的加速效果下降,建议逐个下载或分批下载; 进程勾选:每次重启百度网盘或OpenSpeedy后,需重新勾选所有百度相关进程,否则加速会失效(进程重启后PID会变化,需重新匹配)。 3. 常见问题解决 问题1:加速无效果,实际速度未提升? 解决:①检查是否遗漏百度相关进程(重新搜索“baidu”,确保所有进程都勾选);②重启OpenSpeedy和百度网盘客户端;③确认百度网盘是最新版;④将倍率调整至10倍以上(低于10倍可能效果不明显)。 问题2:启动工具后进程列表为空? 解决:以管理员权限重新运行OpenSpeedy(部分电脑的安全软件会限制工具读取系统进程);关闭360、火绒等安全软件的“进程保护”功能(临时关闭,用完后可恢复)。 问题3:下载过程中提示“文件损坏”“下载失败”? 解决:降低倍率至10倍以下;更换目标文件的下载链接(可能是原链接节点不稳定);暂停下载后清空百度网盘缓存,再重新开始。 问题4:工具界面卡顿、无响应? 解决:关闭其他占用CPU、内存较高的程序(如游戏、视频剪辑软件);OpenSpeedy本身资源占用极低(CPU占用<1%),卡顿多是其他程序导致;重启工具后重新操作。 五、安全与风险提示:理性使用,规避风险 1. 账号安全风险 虽然OpenSpeedy不修改网盘客户端、不窃取账号信息,但该加速方法本质是利用百度网盘的技术漏洞,长期使用可能被网盘系统检测到“异常下载行为”; 风险后果:账号限速加重、临时封禁下载权限、甚至永久封禁(概率较低,但存在可能性); 规避建议:使用非主力账号下载(如专门用于存储、下载的小号),主力账号避免使用该方法,降低账号风险。 2. 系统与软件风险 第三方修改版风险:从非官方渠道下载的OpenSpeedy可能被植入病毒、后门,导致电脑被控制、隐私数据泄露; 系统兼容风险:Win7及以下系统强行运行会导致蓝屏、系统崩溃,需严格遵守操作系统要求; 规避建议:仅从GitHub等官方渠道下载工具;使用前用杀毒软件扫描文件;不随意修改config.ini配置文件(默认配置已适配百度网盘,修改错误会导致工具失效)。 3. 法律与协议风险 该加速方法利用了百度网盘的限速机制漏洞,违反了百度网盘用户协议中“不得通过非官方授权方式突破系统限制”的条款; 风险提示:长期、大规模使用可能面临百度网盘的法律追责(个人非商业使用的追责概率极低,商业用途风险极高); 理性建议:该工具仅用于个人紧急下载需求(如工作文件、学习资料),不建议用于商业分发、大规模下载等场景;若经常需要高速下载,可考虑开通SVIP,享受官方合法的高速下载服务。 六、使用小贴士:让加速更稳定、更高效 定期更新工具:关注更新动态,及时下载新版本(百度网盘可能会调整进程结构,工具需同步更新适配); 监控实际速度:不要依赖百度网盘界面显示的速度,以任务管理器的“接收速度”为准,避免因界面显示误导而盲目提高倍率; 避免长时间连续下载:单次下载时间建议不超过4小时,长时间连续加速可能导致网盘连接中断,可分段下载,中间休息10-15分钟; 清理网盘缓存:下载前在百度网盘设置中清理缓存(“设置”→“传输”→“清理缓存”),减少缓存占用对下载速度的影响; 关闭限速软件:部分校园网、公司网络会安装额外的限速软件,需先关闭或添加百度网盘、OpenSpeedy为白名单,否则会影响加速效果。 -
PyQt5与数据库交互:SQLite/MySQL持久化存储(整合下载工具历史记录) 第17篇:PyQt5与数据库交互:SQLite/MySQL持久化存储(整合下载工具历史记录) 哈喽~ 欢迎来到PyQt5系列的第17篇!前面我们开发的多线程下载工具功能很完整,但有一个致命缺点——关闭程序后,所有下载任务记录都会丢失。想要实现任务记录的永久保存、下次启动自动加载,就必须掌握 PyQt5与数据库的交互。 今天我们就来学习如何在PyQt5中操作数据库,重点讲解轻量级的SQLite(无需额外安装,开箱即用)和主流的MySQL(适合多用户/远程场景),并将数据库功能整合到下载工具中,实现下载任务历史记录的持久化存储。全程搭配完整可运行代码,新手也能轻松上手! 一、核心概念:为什么需要数据库? 在桌面应用开发中,数据库的核心作用是数据持久化——将内存中的临时数据保存到硬盘,程序重启后数据不丢失。对于下载工具来说,数据库可以存储: 历史下载任务的链接、保存路径、下载进度、状态; 用户的个性化设置(如默认下载路径、主题偏好); 下载文件的MD5值、文件大小等元信息。 PyQt5操作数据库的两种方式 方式工具库优点缺点适用场景原生库操作sqlite3(Python内置)、pymysql(MySQL第三方库)语法简单,灵活度高,学习成本低需手动处理数据库连接、事务、异常中小型桌面应用Qt数据库模块QSqlDatabase、QSqlQuery与PyQt5深度集成,支持信号与槽,适合UI联动语法稍复杂,需熟悉Qt的数据库API大型/复杂Qt应用本文选择原生库操作(新手友好),重点讲解SQLite和MySQL的核心用法。 二、实战1:SQLite数据库操作(Python内置,零配置) SQLite是一款嵌入式关系型数据库,无需安装服务端,数据存储在单个文件中,非常适合桌面应用。Python内置sqlite3库,直接导入即可使用。 1. 核心步骤:连接数据库→创建表→增删改查 import sqlite3 import os class SQLiteManager: def __init__(self, db_path="download_history.db"): """初始化数据库连接""" self.db_path = db_path self.conn = None # 数据库连接对象 self.cursor = None # 游标对象,用于执行SQL self.connect() # 初始化时自动连接 self.create_table() # 初始化时自动创建表 def connect(self): """连接SQLite数据库""" try: # 连接数据库(文件不存在则自动创建) self.conn = sqlite3.connect(self.db_path) # 设置游标,用于执行SQL语句 self.cursor = self.conn.cursor() # 解决中文乱码问题 self.cursor.execute("PRAGMA encoding='UTF-8'") print(f"成功连接SQLite数据库:{self.db_path}") except Exception as e: print(f"数据库连接失败:{str(e)}") def create_table(self): """创建下载任务历史表""" create_sql = """ CREATE TABLE IF NOT EXISTS download_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, url TEXT NOT NULL, save_path TEXT NOT NULL, progress INTEGER DEFAULT 0, size TEXT DEFAULT '0 B/未知', speed TEXT DEFAULT '0 B/s', status TEXT DEFAULT '等待中', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ try: self.cursor.execute(create_sql) self.conn.commit() # 提交事务 print("成功创建download_tasks表") except Exception as e: self.conn.rollback() # 出错时回滚 print(f"创建表失败:{str(e)}") def add_task(self, task_id, url, save_path, progress=0, size="0 B/未知", speed="0 B/s", status="等待中"): """添加下载任务到数据库""" insert_sql = """ INSERT INTO download_tasks (task_id, url, save_path, progress, size, speed, status) VALUES (?, ?, ?, ?, ?, ?, ?) """ try: self.cursor.execute(insert_sql, (task_id, url, save_path, progress, size, speed, status)) self.conn.commit() print(f"成功添加任务:{task_id}") return True except Exception as e: self.conn.rollback() print(f"添加任务失败:{str(e)}") return False def update_task(self, task_id, **kwargs): """更新任务信息(支持动态更新字段)""" # kwargs示例:{"progress": 50, "status": "下载中"} fields = [] values = [] for k, v in kwargs.items(): fields.append(f"{k}=?") values.append(v) values.append(task_id) # WHERE条件的值 update_sql = f""" UPDATE download_tasks SET {', '.join(fields)} WHERE task_id=? """ try: self.cursor.execute(update_sql, values) self.conn.commit() print(f"成功更新任务:{task_id}") return True except Exception as e: self.conn.rollback() print(f"更新任务失败:{str(e)}") return False def get_all_tasks(self): """获取所有下载任务""" select_sql = "SELECT * FROM download_tasks ORDER BY create_time DESC" try: self.cursor.execute(select_sql) # 获取字段名(用于构造字典) columns = [desc[0] for desc in self.cursor.description] # 将查询结果转换为字典列表(更易使用) tasks = [] for row in self.cursor.fetchall(): task = dict(zip(columns, row)) tasks.append(task) return tasks except Exception as e: print(f"查询任务失败:{str(e)}") return [] def delete_task(self, task_id): """删除指定任务""" delete_sql = "DELETE FROM download_tasks WHERE task_id=?" try: self.cursor.execute(delete_sql, (task_id,)) self.conn.commit() print(f"成功删除任务:{task_id}") return True except Exception as e: self.conn.rollback() print(f"删除任务失败:{str(e)}") return False def close(self): """关闭数据库连接""" if self.conn: self.conn.close() print("数据库连接已关闭") # -------------------------- 测试代码 -------------------------- if __name__ == "__main__": db = SQLiteManager() # 添加测试任务 db.add_task( task_id="task_001", url="https://www.python.org/static/img/python-logo.png", save_path="python.png", progress=100, size="10 KB/10 KB", speed="2 KB/s", status="已完成" ) # 更新任务 db.update_task("task_001", progress=50, status="已暂停") # 查询所有任务 tasks = db.get_all_tasks() for task in tasks: print(task) # 删除任务 # db.delete_task("task_001") # 关闭连接 db.close()2. 核心知识点解析 参数化查询:使用?作为占位符,避免SQL注入攻击(绝对不要用字符串拼接SQL!); 事务管理:commit()提交事务(执行增删改后必须调用),rollback()出错时回滚; 结果转换:将查询结果转换为字典列表,比元组更易读取字段值; 中文乱码:执行PRAGMA encoding='UTF-8'确保中文正常存储。 三、实战2:MySQL数据库操作(主流关系型数据库) MySQL是一款开源的关系型数据库,适合多用户、远程访问的场景。使用前需安装: 安装MySQL服务端(官网下载); 安装Python驱动:pip install pymysql 1. 核心步骤:连接→建表→增删改查(与SQLite类似) import pymysql class MySQLManager: def __init__(self, host="localhost", port=3306, user="root", password="your_password", db="download_tool"): self.host = host self.port = port self.user = user self.password = password self.db = db self.conn = None self.cursor = None self.connect() self.create_table() def connect(self): """连接MySQL数据库""" try: self.conn = pymysql.connect( host=self.host, port=self.port, user=self.user, password=self.password, database=self.db, charset="utf8mb4" # 支持emoji等特殊字符 ) self.cursor = self.conn.cursor(pymysql.cursors.DictCursor) # 直接返回字典格式 print(f"成功连接MySQL数据库:{self.db}") except Exception as e: print(f"数据库连接失败:{str(e)}") def create_table(self): """创建下载任务表""" create_sql = """ CREATE TABLE IF NOT EXISTS download_tasks ( id INT AUTO_INCREMENT PRIMARY KEY, task_id VARCHAR(50) NOT NULL UNIQUE, url TEXT NOT NULL, save_path TEXT NOT NULL, progress INT DEFAULT 0, size VARCHAR(50) DEFAULT '0 B/未知', speed VARCHAR(50) DEFAULT '0 B/s', status VARCHAR(20) DEFAULT '等待中', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """ try: self.cursor.execute(create_sql) self.conn.commit() print("成功创建download_tasks表") except Exception as e: self.conn.rollback() print(f"创建表失败:{str(e)}") def add_task(self, task_id, url, save_path, progress=0, size="0 B/未知", speed="0 B/s", status="等待中"): """添加任务""" insert_sql = """ INSERT INTO download_tasks (task_id, url, save_path, progress, size, speed, status) VALUES (%s, %s, %s, %s, %s, %s, %s) """ try: self.cursor.execute(insert_sql, (task_id, url, save_path, progress, size, speed, status)) self.conn.commit() return True except Exception as e: self.conn.rollback() print(f"添加任务失败:{str(e)}") return False def update_task(self, task_id, **kwargs): """更新任务""" fields = [] values = [] for k, v in kwargs.items(): fields.append(f"{k}=%s") values.append(v) values.append(task_id) update_sql = f""" UPDATE download_tasks SET {', '.join(fields)} WHERE task_id=%s """ try: self.cursor.execute(update_sql, values) self.conn.commit() return True except Exception as e: self.conn.rollback() print(f"更新任务失败:{str(e)}") return False def get_all_tasks(self): """获取所有任务""" select_sql = "SELECT * FROM download_tasks ORDER BY create_time DESC" try: self.cursor.execute(select_sql) return self.cursor.fetchall() # 直接返回字典列表 except Exception as e: print(f"查询任务失败:{str(e)}") return [] def close(self): """关闭连接""" if self.conn: self.conn.close() print("MySQL连接已关闭") # -------------------------- 测试代码 -------------------------- if __name__ == "__main__": # 注意:替换为你的MySQL账号密码 db = MySQLManager(user="root", password="123456", db="download_tool") db.add_task("task_002", "https://www.baidu.com", "baidu.html", status="已完成") print(db.get_all_tasks()) db.close()2. SQLite vs MySQL 核心区别 对比项SQLiteMySQL占位符?%s游标返回格式需手动转换为字典可通过DictCursor直接返回字典字符集PRAGMA encoding='UTF-8'连接时指定charset='utf8mb4'事务自动提交(增删改需手动commit)默认自动提交(可关闭)适用场景单机桌面应用多用户/远程服务器应用四、终极实战:整合数据库到多线程下载工具 我们将SQLite数据库整合到第16篇的美化版下载工具中,实现任务记录持久化: 启动程序时自动加载历史任务到表格; 添加新任务时自动保存到数据库; 下载进度更新时自动同步到数据库; 关闭程序时自动关闭数据库连接。 完整整合版代码 import sys import time import requests import sqlite3 from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QLineEdit, QPushButton, QFileDialog, QMessageBox, QHeaderView ) from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt5.QtGui import QColor, QFont, QPainter, QBrush, QLinearGradient # -------------------------- 1. 下载线程类 -------------------------- class DownloadThread(QThread): progress_signal = pyqtSignal(int, str, str) status_signal = pyqtSignal(str) finish_signal = pyqtSignal(str) # 传递task_id def __init__(self, task_id, url, save_path): super().__init__() self.task_id = task_id self.url = url self.save_path = save_path self.is_paused = False self.is_canceled = False self.chunk_size = 1024 * 1024 self.downloaded_size = 0 self.total_size = 0 def run(self): try: headers = {} if self.downloaded_size > 0: headers["Range"] = f"bytes={self.downloaded_size}-" response = requests.get(self.url, headers=headers, stream=True, timeout=15) self.total_size = int(response.headers.get("content-length", 0)) + self.downloaded_size with open(self.save_path, "ab") as f: self.status_signal.emit("下载中") start_time = time.time() for chunk in response.iter_content(chunk_size=self.chunk_size): while self.is_paused: time.sleep(0.1) if self.is_canceled: self.status_signal.emit("已取消") self.finish_signal.emit(self.task_id) return if self.is_canceled: self.status_signal.emit("已取消") self.finish_signal.emit(self.task_id) return f.write(chunk) self.downloaded_size += len(chunk) progress = int((self.downloaded_size / self.total_size) * 100) if self.total_size > 0 else 0 downloaded_str = self.format_size(self.downloaded_size) total_str = self.format_size(self.total_size) speed_str = self.calculate_speed(self.downloaded_size, start_time) self.progress_signal.emit(progress, f"{downloaded_str}/{total_str}", speed_str) if self.downloaded_size >= self.total_size and not self.is_canceled: self.status_signal.emit("已完成") elif self.is_canceled: self.status_signal.emit("已取消") else: self.status_signal.emit("已暂停") self.finish_signal.emit(self.task_id) except requests.exceptions.RequestException as e: self.status_signal.emit(f"失败:{str(e)}") self.finish_signal.emit(self.task_id) except Exception as e: self.status_signal.emit(f"失败:{str(e)}") self.finish_signal.emit(self.task_id) def pause(self): self.is_paused = not self.is_paused status = "已暂停" if self.is_paused else "下载中" self.status_signal.emit(status) def cancel(self): self.is_canceled = True self.is_paused = False def format_size(self, size): units = ["B", "KB", "MB", "GB"] index = 0 while size >= 1024 and index < len(units) - 1: size /= 1024 index += 1 return f"{size:.2f} {units[index]}" def calculate_speed(self, downloaded_size, start_time): elapsed_time = time.time() - start_time if elapsed_time <= 0: return "0 B/s" speed = downloaded_size / elapsed_time return self.format_size(speed) + "/s" # -------------------------- 2. 数据库管理类 -------------------------- class DBManager: def __init__(self, db_path="download_history.db"): self.db_path = db_path self.conn = None self.cursor = None self.connect() self.create_table() def connect(self): try: self.conn = sqlite3.connect(self.db_path) self.cursor = self.conn.cursor() self.cursor.execute("PRAGMA encoding='UTF-8'") except Exception as e: QMessageBox.critical(None, "数据库错误", f"连接失败:{str(e)}") def create_table(self): create_sql = """ CREATE TABLE IF NOT EXISTS download_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL UNIQUE, url TEXT NOT NULL, save_path TEXT NOT NULL, progress INTEGER DEFAULT 0, size TEXT DEFAULT '0 B/未知', speed TEXT DEFAULT '0 B/s', status TEXT DEFAULT '等待中', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ try: self.cursor.execute(create_sql) self.conn.commit() except Exception as e: self.conn.rollback() QMessageBox.critical(None, "数据库错误", f"创建表失败:{str(e)}") def add_task(self, task_id, url, save_path, progress=0, size="0 B/未知", speed="0 B/s", status="等待中"): insert_sql = """ INSERT OR IGNORE INTO download_tasks (task_id, url, save_path, progress, size, speed, status) VALUES (?, ?, ?, ?, ?, ?, ?) """ try: self.cursor.execute(insert_sql, (task_id, url, save_path, progress, size, speed, status)) self.conn.commit() return True except Exception as e: self.conn.rollback() QMessageBox.warning(None, "添加失败", f"任务已存在或数据库错误:{str(e)}") return False def update_task(self, task_id, **kwargs): fields = [] values = [] for k, v in kwargs.items(): fields.append(f"{k}=?") values.append(v) values.append(task_id) update_sql = f"UPDATE download_tasks SET {', '.join(fields)} WHERE task_id=?" try: self.cursor.execute(update_sql, values) self.conn.commit() return True except Exception as e: self.conn.rollback() QMessageBox.warning(None, "更新失败", f"{str(e)}") return False def get_all_tasks(self): select_sql = "SELECT * FROM download_tasks ORDER BY create_time DESC" try: self.cursor.execute(select_sql) columns = [desc[0] for desc in self.cursor.description] tasks = [] for row in self.cursor.fetchall(): tasks.append(dict(zip(columns, row))) return tasks except Exception as e: QMessageBox.warning(None, "查询失败", f"{str(e)}") return [] def delete_task(self, task_id): delete_sql = "DELETE FROM download_tasks WHERE task_id=?" try: self.cursor.execute(delete_sql, (task_id,)) self.conn.commit() return True except Exception as e: self.conn.rollback() QMessageBox.warning(None, "删除失败", f"{str(e)}") return False def close(self): if self.conn: self.conn.close() # -------------------------- 3. 主窗口类 -------------------------- class DownloaderWindow(QMainWindow): def __init__(self): super().__init__() self.init_ui() # 初始化数据库 self.db = DBManager() # 任务存储:{task_id: {"thread": 线程实例, ...}} self.download_tasks = {} self.current_task_id = 0 # 加载历史任务 self.load_history_tasks() # 加载QSS样式 self.load_qss() def init_ui(self): self.setWindowTitle("多线程下载工具(带数据库持久化)") self.resize(900, 600) self.setMinimumSize(800, 500) self.setAttribute(Qt.WA_TranslucentBackground) self.setWindowFlags(Qt.Window | Qt.WindowMinimizeButtonHint | Qt.WindowCloseButtonHint) central_widget = QWidget() central_widget.setObjectName("centralWidget") self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(15) main_layout.setContentsMargins(20, 20, 20, 20) # 顶部任务添加区 add_task_layout = QHBoxLayout() self.url_edit = QLineEdit() self.url_edit.setPlaceholderText("请输入下载链接") self.url_edit.setObjectName("urlEdit") self.path_edit = QLineEdit() self.path_edit.setPlaceholderText("请选择保存路径") self.path_edit.setObjectName("pathEdit") self.browse_btn = QPushButton("浏览") self.add_btn = QPushButton("添加任务") self.start_all_btn = QPushButton("开始所有") self.clear_btn = QPushButton("清空历史") # 新增清空按钮 for btn in [self.browse_btn, self.add_btn, self.start_all_btn, self.clear_btn]: btn.setFixedSize(80, 35) btn.setObjectName("funcBtn") add_task_layout.addWidget(self.url_edit) add_task_layout.addWidget(self.path_edit) add_task_layout.addWidget(self.browse_btn) add_task_layout.addWidget(self.add_btn) add_task_layout.addWidget(self.start_all_btn) add_task_layout.addWidget(self.clear_btn) # 中部任务列表 self.task_table = QTableWidget() self.task_table.setColumnCount(9) # 新增task_id列(隐藏) self.task_table.setHorizontalHeaderLabels([ "任务ID", "链接", "保存路径", "进度", "大小", "速度", "状态", "操作", "隐藏ID" ]) self.task_table.setObjectName("taskTable") self.task_table.horizontalHeader().setStretchLastSection(True) self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.task_table.verticalHeader().setVisible(False) self.task_table.setEditTriggers(QTableWidget.NoEditTriggers) self.task_table.setColumnHidden(8, True) # 隐藏task_id列 # 底部状态栏 self.status_label = QLabel("就绪:已加载历史任务") self.status_label.setObjectName("statusLabel") main_layout.addLayout(add_task_layout) main_layout.addWidget(self.task_table) main_layout.addWidget(self.status_label) # 绑定信号 self.browse_btn.clicked.connect(self.choose_save_path) self.add_btn.clicked.connect(self.add_download_task) self.start_all_btn.clicked.connect(self.start_all_tasks) self.clear_btn.clicked.connect(self.clear_all_tasks) def load_history_tasks(self): """加载历史任务到表格""" tasks = self.db.get_all_tasks() for task in tasks: row = self.task_table.rowCount() self.task_table.insertRow(row) # 填充数据 self.task_table.setItem(row, 0, QTableWidgetItem(str(row + 1))) self.task_table.setItem(row, 1, QTableWidgetItem(task["url"])) self.task_table.setItem(row, 2, QTableWidgetItem(task["save_path"])) self.task_table.setItem(row, 3, QTableWidgetItem(f"{task['progress']}%")) self.task_table.setItem(row, 4, QTableWidgetItem(task["size"])) self.task_table.setItem(row, 5, QTableWidgetItem(task["speed"])) self.task_table.setItem(row, 6, QTableWidgetItem(task["status"])) # 隐藏的task_id列 self.task_table.setItem(row, 8, QTableWidgetItem(task["task_id"])) # 添加操作按钮 btn_layout = QHBoxLayout() start_btn = QPushButton("开始") pause_btn = QPushButton("暂停") cancel_btn = QPushButton("取消") del_btn = QPushButton("删除") # 新增删除按钮 for btn in [start_btn, pause_btn, cancel_btn, del_btn]: btn.setFixedSize(50, 30) btn_layout.addWidget(start_btn) btn_layout.addWidget(pause_btn) btn_layout.addWidget(cancel_btn) btn_layout.addWidget(del_btn) btn_widget = QWidget() btn_widget.setLayout(btn_layout) self.task_table.setCellWidget(row, 7, btn_widget) # 绑定按钮信号 task_id = task["task_id"] start_btn.clicked.connect(lambda checked, tid=task_id: self.start_single_task(tid)) pause_btn.clicked.connect(lambda checked, tid=task_id: self.pause_single_task(tid)) cancel_btn.clicked.connect(lambda checked, tid=task_id: self.cancel_single_task(tid)) del_btn.clicked.connect(lambda checked, tid=task_id: self.delete_single_task(tid)) # 存储任务信息 self.download_tasks[task_id] = { "thread": None, "url": task["url"], "save_path": task["save_path"], "start_btn": start_btn, "pause_btn": pause_btn, "cancel_btn": cancel_btn } def add_download_task(self): url = self.url_edit.text().strip() save_path = self.path_edit.text().strip() if not url or not save_path: QMessageBox.warning(self, "提示", "请输入链接和保存路径!") return # 生成唯一task_id task_id = f"task_{int(time.time() * 1000)}_{self.current_task_id}" self.current_task_id += 1 # 添加到数据库 if self.db.add_task(task_id, url, save_path): # 添加到表格 row = self.task_table.rowCount() self.task_table.insertRow(row) self.task_table.setItem(row, 0, QTableWidgetItem(str(row + 1))) self.task_table.setItem(row, 1, QTableWidgetItem(url)) self.task_table.setItem(row, 2, QTableWidgetItem(save_path)) self.task_table.setItem(row, 3, QTableWidgetItem("0%")) self.task_table.setItem(row, 4, QTableWidgetItem("0 B/未知")) self.task_table.setItem(row, 5, QTableWidgetItem("0 B/s")) self.task_table.setItem(row, 6, QTableWidgetItem("等待中")) self.task_table.setItem(row, 8, QTableWidgetItem(task_id)) # 添加操作按钮 btn_layout = QHBoxLayout() start_btn = QPushButton("开始") pause_btn = QPushButton("暂停") cancel_btn = QPushButton("取消") del_btn = QPushButton("删除") for btn in [start_btn, pause_btn, cancel_btn, del_btn]: btn.setFixedSize(50, 30) btn_layout.addWidget(start_btn) btn_layout.addWidget(pause_btn) btn_layout.addWidget(cancel_btn) btn_layout.addWidget(del_btn) btn_widget = QWidget() btn_widget.setLayout(btn_layout) self.task_table.setCellWidget(row, 7, btn_widget) # 绑定信号 start_btn.clicked.connect(lambda checked, tid=task_id: self.start_single_task(tid)) pause_btn.clicked.connect(lambda checked, tid=task_id: self.pause_single_task(tid)) cancel_btn.clicked.connect(lambda checked, tid=task_id: self.cancel_single_task(tid)) del_btn.clicked.connect(lambda checked, tid=task_id: self.delete_single_task(tid)) # 存储任务 self.download_tasks[task_id] = { "thread": None, "url": url, "save_path": save_path, "start_btn": start_btn, "pause_btn": pause_btn, "cancel_btn": cancel_btn } self.status_label.setText(f"已添加任务:{task_id}") self.url_edit.clear() self.path_edit.clear() def start_single_task(self, task_id): task = self.download_tasks.get(task_id) if not task: return if task["thread"] is None: task["thread"] = DownloadThread(task_id, task["url"], task["save_path"]) # 绑定信号 task["thread"].progress_signal.connect(lambda p, s, sp, tid=task_id: self.update_task_progress(tid, p, s, sp)) task["thread"].status_signal.connect(lambda st, tid=task_id: self.update_task_status(tid, st)) task["thread"].finish_signal.connect(lambda tid=task_id: self.on_task_finished(tid)) task["thread"].start() task["start_btn"].setEnabled(False) task["pause_btn"].setEnabled(True) task["cancel_btn"].setEnabled(True) self.status_label.setText(f"任务 {task_id} 开始下载...") def pause_single_task(self, task_id): task = self.download_tasks.get(task_id) if task and task["thread"] and task["thread"].isRunning(): task["thread"].pause() def cancel_single_task(self, task_id): task = self.download_tasks.get(task_id) if task and task["thread"]: task["thread"].cancel() task["start_btn"].setEnabled(False) task["pause_btn"].setEnabled(False) def delete_single_task(self, task_id): if QMessageBox.question(self, "确认删除", f"确定删除任务 {task_id} 吗?", QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: # 从数据库删除 if self.db.delete_task(task_id): # 从表格删除 for row in range(self.task_table.rowCount()): item = self.task_table.item(row, 8) if item and item.text() == task_id: self.task_table.removeRow(row) break # 从内存删除 del self.download_tasks[task_id] self.status_label.setText(f"已删除任务 {task_id}") def clear_all_tasks(self): if QMessageBox.question(self, "确认清空", "确定清空所有历史任务吗?", QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: # 清空表格 self.task_table.setRowCount(0) # 清空内存 self.download_tasks.clear() # 清空数据库 for task in self.db.get_all_tasks(): self.db.delete_task(task["task_id"]) self.status_label.setText("已清空所有历史任务") def update_task_progress(self, task_id, progress, size_str, speed_str): # 更新数据库 self.db.update_task(task_id, progress=progress, size=size_str, speed=speed_str) # 更新表格 for row in range(self.task_table.rowCount()): item = self.task_table.item(row, 8) if item and item.text() == task_id: self.task_table.setItem(row, 3, QTableWidgetItem(f"{progress}%")) self.task_table.setItem(row, 4, QTableWidgetItem(size_str)) self.task_table.setItem(row, 5, QTableWidgetItem(speed_str)) break def update_task_status(self, task_id, status): # 更新数据库 self.db.update_task(task_id, status=status) # 更新表格 for row in range(self.task_table.rowCount()): item = self.task_table.item(row, 8) if item and item.text() == task_id: self.task_table.setItem(row, 6, QTableWidgetItem(status)) # 更新状态文字颜色 status_item = self.task_table.item(row, 6) if status == "下载中": status_item.setForeground(QColor(64, 158, 255)) elif status == "已完成": status_item.setForeground(QColor(103, 194, 58)) elif status == "已暂停": status_item.setForeground(QColor(230, 162, 60)) elif status == "已取消": status_item.setForeground(QColor(144, 147, 153)) else: status_item.setForeground(QColor(245, 108, 108)) break def on_task_finished(self, task_id): task = self.download_tasks.get(task_id) if task: task["start_btn"].setEnabled(False) task["pause_btn"].setEnabled(False) self.status_label.setText(f"任务 {task_id} 已完成!") def choose_save_path(self): file_path, _ = QFileDialog.getSaveFileName(self, "选择保存路径", "", "All Files (*.*)") if file_path: self.path_edit.setText(file_path) def load_qss(self): qss = """ #centralWidget { background-color: transparent; } QLineEdit { border: 2px solid #e0e6ed; border-radius: 8px; padding: 8px 12px; font-size: 14px; color: #2c3e50; background-color: white; } QLineEdit:focus { border-color: #409eff; outline: none; } QLineEdit::placeholder { color: #909399; } #funcBtn { background-color: #409eff; color: white; border: none; border-radius: 8px; font-size: 14px; } #funcBtn:hover { background-color: #3390e7; } #funcBtn:pressed { background-color: #2680dc; } #taskTable { background-color: white; border: none; border-radius: 8px; gridline-color: #e0e6ed; font-size: 13px; } #taskTable QHeaderView::section { background-color: #409eff; color: white; border: none; padding: 10px; text-align: center; font-weight: bold; font-size: 14px; } #taskTable::item:alternate { background-color: #f8fafc; } #taskTable::item:selected { background-color: #e6f7ff; color: #2c3e50; } #taskTable::item:hover { background-color: #f0f8ff; } QPushButton[text="开始"] { background-color: #67c23a; color: white; border: none; border-radius: 4px; padding: 4px 8px; } QPushButton[text="暂停"] { background-color: #e6a23c; color: white; border: none; border-radius: 4px; padding: 4px 8px; } QPushButton[text="取消"] { background-color: #f56c6c; color: white; border: none; border-radius: 4px; padding: 4px 8px; } QPushButton[text="删除"] { background-color: #909399; color: white; border: none; border-radius: 4px; padding: 4px 8px; } #statusLabel { color: #606266; font-size: 12px; padding: 5px 0; } """ self.setStyleSheet(qss) def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) gradient = QLinearGradient(0, 0, self.width(), self.height()) gradient.setColorAt(0, QColor(245, 247, 250)) gradient.setColorAt(1, QColor(230, 235, 240)) painter.setBrush(QBrush(gradient)) painter.setPen(Qt.NoPen) painter.drawRoundedRect(self.rect(), 15, 15) def closeEvent(self, event): reply = QMessageBox.question(self, "关闭确认", "确定关闭吗?正在下载的任务将被取消!", QMessageBox.Yes | QMessageBox.No) if reply == QMessageBox.Yes: # 停止所有线程 for task in self.download_tasks.values(): if task["thread"] and task["thread"].isRunning(): task["thread"].cancel() task["thread"].wait() # 关闭数据库 self.db.close() event.accept() else: event.ignore() # -------------------------- 程序入口 -------------------------- if __name__ == "__main__": app = QApplication(sys.argv) app.setStyle("Fusion") window = DownloaderWindow() window.show() sys.exit(app.exec_())mkj2moje.png图片 整合核心亮点 任务持久化:启动程序时自动从数据库加载历史任务,添加/更新/删除任务时同步到数据库; 唯一任务ID:用时间戳+计数器生成唯一task_id,避免任务重复; 线程-数据库联动:下载进度更新时,通过信号同步更新数据库和表格; 安全关闭:窗口关闭时,先停止所有下载线程,再关闭数据库连接,避免数据丢失。 五、数据库交互常见问题排查 1. 中文乱码 SQLite:执行PRAGMA encoding='UTF-8'; MySQL:连接时指定charset='utf8mb4',表字符集设为utf8mb4。 2. 多线程操作数据库冲突 问题:多个下载线程同时更新数据库,导致锁表或数据错乱; 解决方案:使用数据库锁(sqlite3的timeout参数),或在主线程统一处理数据库操作。 3. 任务重复添加 解决方案:SQLite使用INSERT OR IGNORE,MySQL使用INSERT ... ON DUPLICATE KEY UPDATE,确保task_id唯一。 4. 数据库文件权限不足 问题:无法创建或写入数据库文件; 解决方案:将数据库文件保存到用户目录(如C:/Users/用户名/Documents),避免系统盘根目录。 六、进阶拓展方向 支持MySQL远程连接:将DBManager改为支持SQLite/MySQL切换,满足不同场景需求; 任务备份与恢复:导出数据库为SQL文件,支持一键恢复; 数据加密:对敏感字段(如用户密码)进行加密存储; 分页加载历史任务:当任务数量过多时,实现分页查询,提升界面加载速度。 总结 数据库核心作用:实现桌面应用的数据持久化,程序重启后数据不丢失; SQLite vs MySQL:SQLite适合单机应用,MySQL适合多用户/远程场景; 整合关键:将数据库操作与UI逻辑分离,通过信号与槽实现线程-数据库-界面的联动; 安全要点:使用参数化查询避免SQL注入,合理管理事务,安全关闭数据库连接。 下一章我们将学习PyQt5打包发布——如何将写好的Python程序打包成exe可执行文件,分发给用户直接运行,无需安装Python环境!如果在数据库整合中遇到问题,欢迎在评论区留言讨论~ -
夸克网盘高速下载技巧:快传功能绕过限速,非会员实测3-10MB/s(2026最新) 引言 使用夸克网盘时,你是否经常被“龟速下载”劝退?非会员下载速度动辄几十KB/s,甚至部分文件直接限制下载,严重影响使用体验。最近笔者因误删软件安装包,急需从夸克网盘重新下载,却遭遇限速难题,一番摸索后发现了一个隐藏技巧——利用PC端「快传」功能,无需会员即可绕过限速检测,实现3-10MB/s的高速下载!今天就把这个实测有效的方法分享给大家,附详细步骤和原理解析。 一、核心原理:为什么「快传」能突破限速? 夸克网盘的普通下载通道会对非会员用户进行带宽限制,而「快传」功能原本是为了实现设备间快速传输文件设计的,采用了独立的传输协议,未接入普通下载的限速逻辑。通过“自发送+本地下载”的组合,可让文件通过快传通道传输,从而绕过非会员的限速检测,直接以网络带宽上限速度下载。 实测环境:100M宽带,普通下载速度20-50KB/s,通过快传功能下载稳定在3-8MB/s,峰值可达10MB/s,下载效率提升数十倍。 二、详细操作步骤(附图文教程) 前提条件 安装最新版夸克网盘PC客户端(建议V3.18.2及以上版本,旧版本可能无该功能) 已登录夸克账号,且需下载的文件已保存至个人网盘 步骤1:打开夸克网盘PC端,找到「快传」功能 启动夸克网盘客户端,在左侧导航栏中找到「快传」选项(图标为一个小飞机样式),点击进入快传页面。 快传功能入口图片 步骤2:创建「发给自己」的传输任务 在快传页面中,选择「发给自己」选项(无需添加其他联系人),然后点击「发送网盘文件」按钮,进入文件选择界面。 创建自传输任务图片 步骤3:选择需下载的文件/文件夹 在网盘文件列表中,勾选需要高速下载的单个文件或整个文件夹(支持批量选择),勾选完成后点击「确定」,发起自传输任务。 选择下载文件图片 步骤4:发起高速下载 传输任务创建后,在快传列表中找到刚发送的文件,右键点击该任务,选择「下载到本地」,然后选择保存路径,即可开始高速下载。 发起下载图片 步骤5:查看下载速度 下载开始后,可在客户端底部的下载任务栏中查看实时速度,实测稳定在3-10MB/s,具体速度取决于你的网络带宽上限。 下载速度展示图片 下载完成截图图片 三、注意事项(避坑指南) 版本要求:必须使用夸克网盘PC端(V3.18.2及以上),手机端快传功能暂不支持该操作; 文件限制:支持单个大文件(实测2GB文件无压力)和文件夹批量下载,无文件类型限制; 网络环境:建议使用有线网络或优质Wi-Fi,避免因网络不稳定导致速度波动; 账号安全:仅支持“发给自己”,无需分享给他人,避免文件泄露; 功能稳定性:该方法为夸克网盘功能逻辑漏洞,若后续版本修复,本文会及时更新替代方案。 四、常见问题解答 Q1:为什么我找不到「快传」功能? A1:请检查夸克网盘客户端是否为最新版本,旧版本需升级至V3.18.2及以上;若仍未找到,可在客户端「设置」-「功能管理」中查看是否已启用快传功能。 Q2:下载速度达不到3MB/s怎么办? A2:首先检查网络带宽(100M宽带理论上限约12MB/s),其次关闭其他占用网络的应用(如视频软件、游戏),最后重新创建快传任务尝试。 Q3:该方法是否需要会员? A3:无需开通夸克网盘会员,普通用户即可使用,完全免费。 结语 以上就是夸克网盘高速下载的隐藏技巧,通过「快传」功能绕过限速,无需会员就能享受媲美会员的下载速度,对于经常需要从夸克网盘下载大文件的用户来说,无疑是提升效率的实用方法。如果本文对你有帮助,欢迎点赞、收藏、转发给需要的朋友;若操作过程中遇到问题,或有其他夸克网盘使用技巧,欢迎在评论区留言交流! -
PyQt5阶段三实战项目:多线程文件下载工具(仿迅雷核心功能) 第15篇:阶段三实战项目:仿照下载器实现多线程文件下载工具(完整代码) 哈喽~ 欢迎来到PyQt5系列的第15篇,也是阶段三的收官实战项目!经过前几章的学习,我们已经掌握了信号与槽、自定义信号、事件处理和多线程编程的核心知识。今天我们将这些知识点整合起来,开发一个仿照主流下载器的多线程文件下载工具,实现多任务下载、进度实时显示、暂停/继续/取消下载、下载速度计算等核心功能,彻底解决单线程下载卡顿、效率低的问题! mke2owv3.png图片 一、项目需求分析:对标主流下载器核心功能 我们聚焦下载器的核心实用功能,本次项目实现以下需求: 基础功能:支持输入下载链接、自定义保存路径、启动下载、暂停/继续下载、取消下载; 多任务管理:支持同时添加多个下载任务,每个任务独立运行,互不干扰; 进度展示:实时显示每个任务的下载进度、已下载大小、总大小、下载速度; 交互体验:任务列表清晰展示所有任务状态(等待中/下载中/已暂停/已完成/下载失败),操作按钮状态随任务状态动态切换; 线程安全:严格遵循“子线程不操作UI”原则,通过自定义信号传递数据,避免界面卡顿和程序崩溃; 异常处理:捕获网络异常、文件读写异常、链接无效等问题,给出明确的错误提示。 二、技术选型:整合阶段三核心知识点 本次项目严格基于阶段三所学内容,技术栈如下: 技术点应用场景QThread多线程每个下载任务对应一个子线程,避免阻塞主线程自定义信号子线程向主线程传递下载进度、速度、状态等数据信号与槽绑定按钮点击触发下载控制,子线程信号触发UI更新事件处理窗口关闭时安全停止所有下载线程,释放资源QTableWidget展示多下载任务的详细信息(链接、进度、状态等)QFileDialog选择文件保存路径,提升用户体验网络请求(requests库)分块下载文件,支持断点续传基础逻辑三、项目架构设计:模块化分工 为了让代码结构清晰、易于维护,我们采用模块化设计,将项目分为3个核心部分: 下载线程类(DownloadThread):继承QThread,负责单个任务的下载逻辑,通过自定义信号传递进度和状态; 任务管理类(TaskManager):管理所有下载任务,实现任务的添加、删除、暂停/继续等统一控制; 主窗口类(DownloaderWindow):负责界面布局、用户交互、接收子线程信号并更新UI。 核心架构逻辑:用户在主窗口添加下载任务 → 任务管理类创建对应的下载线程 → 子线程执行下载并发射信号 → 主窗口接收信号更新任务列表 → 用户通过按钮操作任务状态。 四、完整代码实现(可直接运行) 注意:需提前安装requests库(用于网络请求),执行命令:pip install requestsimport sys import time import requests from datetime import datetime from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QLineEdit, QPushButton, QFileDialog, QMessageBox, QHeaderView ) from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt5.QtGui import QColor, QFont # -------------------------- 1. 下载线程类:单个任务的下载逻辑 -------------------------- class DownloadThread(QThread): # 自定义信号:传递下载进度、速度、状态等数据 progress_signal = pyqtSignal(int, str, str) # 进度(0-100)、已下载大小、速度 status_signal = pyqtSignal(str) # 任务状态(下载中/已暂停/已完成/失败) finish_signal = pyqtSignal() # 任务完成信号 def __init__(self, url, save_path): super().__init__() self.url = url # 下载链接 self.save_path = save_path # 保存路径 self.is_paused = False # 暂停标志位 self.is_canceled = False # 取消标志位 self.chunk_size = 1024 * 1024 # 分块大小:1MB/块 self.downloaded_size = 0 # 已下载大小(字节) self.total_size = 0 # 文件总大小(字节) def run(self): """子线程核心执行方法:文件下载逻辑""" try: # 发送请求,获取文件大小(支持断点续传的基础) headers = {} if self.downloaded_size > 0: headers["Range"] = f"bytes={self.downloaded_size}-" # 断点续传:从已下载位置开始 response = requests.get(self.url, headers=headers, stream=True, timeout=15) self.total_size = int(response.headers.get("content-length", 0)) + self.downloaded_size # 打开文件,追加模式写入(支持断点续传) with open(self.save_path, "ab") as f: self.status_signal.emit("下载中") start_time = time.time() # 计时:用于计算下载速度 for chunk in response.iter_content(chunk_size=self.chunk_size): # 处理暂停:暂停时阻塞循环,直到取消暂停 while self.is_paused: time.sleep(0.1) if self.is_canceled: self.status_signal.emit("已取消") self.finish_signal.emit() return # 处理取消:直接终止下载 if self.is_canceled: self.status_signal.emit("已取消") self.finish_signal.emit() return # 写入分块数据 f.write(chunk) self.downloaded_size += len(chunk) # 计算进度、已下载大小、速度 progress = int((self.downloaded_size / self.total_size) * 100) if self.total_size > 0 else 0 downloaded_str = self.format_size(self.downloaded_size) total_str = self.format_size(self.total_size) speed_str = self.calculate_speed(self.downloaded_size, start_time) # 发射进度信号(每块发射一次,避免UI刷新太频繁) self.progress_signal.emit(progress, f"{downloaded_str}/{total_str}", speed_str) # 下载完成判断 if self.downloaded_size >= self.total_size and not self.is_canceled: self.status_signal.emit("已完成") elif self.is_canceled: self.status_signal.emit("已取消") else: self.status_signal.emit("已暂停") except requests.exceptions.RequestException as e: self.status_signal.emit(f"失败:{str(e)}") except Exception as e: self.status_signal.emit(f"失败:{str(e)}") finally: self.finish_signal.emit() def pause(self): """暂停下载""" self.is_paused = not self.is_paused status = "已暂停" if self.is_paused else "下载中" self.status_signal.emit(status) def cancel(self): """取消下载""" self.is_canceled = True self.is_paused = False # 取消暂停,让循环退出 def format_size(self, size): """字节大小格式化:转换为KB/MB/GB""" units = ["B", "KB", "MB", "GB"] index = 0 while size >= 1024 and index < len(units) - 1: size /= 1024 index += 1 return f"{size:.2f} {units[index]}" def calculate_speed(self, downloaded_size, start_time): """计算下载速度:单位 KB/s 或 MB/s""" elapsed_time = time.time() - start_time if elapsed_time <= 0: return "0 B/s" speed = downloaded_size / elapsed_time return self.format_size(speed) + "/s" # -------------------------- 2. 主窗口类:界面与交互逻辑 -------------------------- class DownloaderWindow(QMainWindow): def __init__(self): super().__init__() self.init_ui() self.download_tasks = {} # 存储下载任务:{任务索引: (线程实例, 链接, 保存路径)} self.current_task_index = 0 # 任务索引计数器 def init_ui(self): """初始化界面布局""" self.setWindowTitle("多线程文件下载工具") self.resize(900, 600) self.setMinimumSize(800, 500) # 中心控件 central_widget = QWidget() self.setCentralWidget(central_widget) # 主布局:垂直布局 main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(15) main_layout.setContentsMargins(20, 20, 20, 20) # ---------- 顶部:下载任务添加区 ---------- add_task_layout = QHBoxLayout() add_task_layout.setSpacing(10) self.url_edit = QLineEdit() self.url_edit.setPlaceholderText("请输入下载链接(如:https://xxx.xxx/file.zip)") self.url_edit.setStyleSheet("padding: 8px; font-size: 14px;") self.path_edit = QLineEdit() self.path_edit.setPlaceholderText("请选择保存路径") self.path_edit.setStyleSheet("padding: 8px; font-size: 14px;") self.browse_btn = QPushButton("浏览") self.add_btn = QPushButton("添加任务") self.start_all_btn = QPushButton("开始所有任务") for btn in [self.browse_btn, self.add_btn, self.start_all_btn]: btn.setFixedSize(80, 35) btn.setStyleSheet("font-size: 14px;") add_task_layout.addWidget(self.url_edit) add_task_layout.addWidget(self.path_edit) add_task_layout.addWidget(self.browse_btn) add_task_layout.addWidget(self.add_btn) add_task_layout.addWidget(self.start_all_btn) # ---------- 中部:下载任务列表(QTableWidget) ---------- self.task_table = QTableWidget() # 列标题:索引、链接、保存路径、进度、大小、速度、状态、操作 self.task_table.setColumnCount(8) self.task_table.setHorizontalHeaderLabels([ "任务ID", "下载链接", "保存路径", "进度", "大小", "速度", "状态", "操作" ]) # 表格样式优化 self.task_table.horizontalHeader().setStretchLastSection(True) self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.task_table.verticalHeader().setVisible(False) self.task_table.setEditTriggers(QTableWidget.NoEditTriggers) # 禁止编辑单元格 # ---------- 底部:状态栏 ---------- self.status_label = QLabel("就绪:可添加下载任务") self.status_label.setStyleSheet("font-size: 12px; color: #666;") # ---------- 添加所有控件到主布局 ---------- main_layout.addLayout(add_task_layout) main_layout.addWidget(self.task_table) main_layout.addWidget(self.status_label) # ---------- 绑定信号与槽 ---------- self.browse_btn.clicked.connect(self.choose_save_path) self.add_btn.clicked.connect(self.add_download_task) self.start_all_btn.clicked.connect(self.start_all_tasks) def choose_save_path(self): """选择文件保存路径""" file_path, _ = QFileDialog.getSaveFileName( self, "选择保存路径", "", "All Files (*.*)" ) if file_path: self.path_edit.setText(file_path) def add_download_task(self): """添加下载任务到列表""" url = self.url_edit.text().strip() save_path = self.path_edit.text().strip() # 输入验证 if not url: QMessageBox.warning(self, "提示", "请输入下载链接!") return if not save_path: QMessageBox.warning(self, "提示", "请选择保存路径!") return # 添加任务到表格 row = self.task_table.rowCount() self.task_table.insertRow(row) # 填充任务基本信息 self.task_table.setItem(row, 0, QTableWidgetItem(str(self.current_task_index))) self.task_table.setItem(row, 1, QTableWidgetItem(url)) self.task_table.setItem(row, 2, QTableWidgetItem(save_path)) self.task_table.setItem(row, 3, QTableWidgetItem("0%")) self.task_table.setItem(row, 4, QTableWidgetItem("0 B/未知")) self.task_table.setItem(row, 5, QTableWidgetItem("0 B/s")) self.task_table.setItem(row, 6, QTableWidgetItem("等待中")) # 添加操作按钮(暂停/继续/取消) btn_layout = QHBoxLayout() start_btn = QPushButton("开始") pause_btn = QPushButton("暂停") cancel_btn = QPushButton("取消") for btn in [start_btn, pause_btn, cancel_btn]: btn.setFixedSize(60, 30) btn_layout.addWidget(btn) btn_widget = QWidget() btn_widget.setLayout(btn_layout) self.task_table.setCellWidget(row, 7, btn_widget) # 绑定按钮信号 start_btn.clicked.connect(lambda: self.start_single_task(row)) pause_btn.clicked.connect(lambda: self.pause_single_task(row)) cancel_btn.clicked.connect(lambda: self.cancel_single_task(row)) # 存储任务信息 self.download_tasks[row] = { "thread": None, "url": url, "save_path": save_path, "start_btn": start_btn, "pause_btn": pause_btn, "cancel_btn": cancel_btn } # 更新状态 self.status_label.setText(f"已添加任务 {self.current_task_index}:{url}") self.current_task_index += 1 # 清空输入框 self.url_edit.clear() self.path_edit.clear() def start_single_task(self, row): """启动单个下载任务""" task = self.download_tasks.get(row) if not task: return # 创建下载线程 if task["thread"] is None: task["thread"] = DownloadThread(task["url"], task["save_path"]) # 绑定线程信号 task["thread"].progress_signal.connect(lambda p, s, sp: self.update_task_progress(row, p, s, sp)) task["thread"].status_signal.connect(lambda st: self.update_task_status(row, st)) task["thread"].finish_signal.connect(lambda: self.on_task_finished(row)) # 启动线程 task["thread"].start() task["start_btn"].setEnabled(False) task["pause_btn"].setEnabled(True) task["cancel_btn"].setEnabled(True) self.status_label.setText(f"任务 {row} 开始下载...") def pause_single_task(self, row): """暂停/继续单个任务""" task = self.download_tasks.get(row) if task and task["thread"] and task["thread"].isRunning(): task["thread"].pause() def cancel_single_task(self, row): """取消单个任务""" task = self.download_tasks.get(row) if task and task["thread"]: task["thread"].cancel() task["start_btn"].setEnabled(False) task["pause_btn"].setEnabled(False) task["cancel_btn"].setEnabled(False) def start_all_tasks(self): """启动所有等待中的任务""" for row in range(self.task_table.rowCount()): status = self.task_table.item(row, 6).text() if status == "等待中": self.start_single_task(row) def update_task_progress(self, row, progress, size_str, speed_str): """更新单个任务的进度、大小、速度""" self.task_table.setItem(row, 3, QTableWidgetItem(f"{progress}%")) self.task_table.setItem(row, 4, QTableWidgetItem(size_str)) self.task_table.setItem(row, 5, QTableWidgetItem(speed_str)) # 进度条样式(可选:设置单元格背景色) progress_item = self.task_table.item(row, 3) if progress < 50: progress_item.setBackground(QColor(255, 240, 240)) else: progress_item.setBackground(QColor(240, 255, 240)) def update_task_status(self, row, status): """更新单个任务的状态""" self.task_table.setItem(row, 6, QTableWidgetItem(status)) status_item = self.task_table.item(row, 6) if status == "下载中": status_item.setForeground(QColor(34, 139, 34)) elif status == "已暂停": status_item.setForeground(QColor(255, 165, 0)) elif status == "已完成": status_item.setForeground(QColor(0, 128, 0)) elif status == "已取消": status_item.setForeground(QColor(128, 128, 128)) else: status_item.setForeground(QColor(220, 20, 60)) def on_task_finished(self, row): """任务完成后的清理工作""" task = self.download_tasks.get(row) if task: task["start_btn"].setEnabled(False) task["pause_btn"].setEnabled(False) self.status_label.setText(f"任务 {row} 已完成!") def closeEvent(self, event): """窗口关闭时安全停止所有线程""" reply = QMessageBox.question( self, "关闭确认", "确定要关闭下载工具吗?正在下载的任务将被取消!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: # 停止所有正在运行的线程 for row, task in self.download_tasks.items(): if task["thread"] and task["thread"].isRunning(): task["thread"].cancel() task["thread"].wait() event.accept() else: event.ignore() if __name__ == "__main__": app = QApplication(sys.argv) window = DownloaderWindow() window.show() sys.exit(app.exec_())五、核心功能解析 1. 多线程下载核心逻辑 分块下载:通过response.iter_content(chunk_size=1MB)分块读取文件,避免一次性加载大文件到内存; 断点续传基础:利用HTTP的Range请求头,记录已下载大小,暂停后重新下载时从断点开始; 线程安全控制:通过is_paused和is_canceled两个标志位控制下载循环,避免强制终止线程导致资源泄漏。 2. 自定义信号的应用 子线程定义了3个核心信号,实现与主线程的安全通信: progress_signal:传递下载进度、已下载大小、下载速度,用于实时更新表格; status_signal:传递任务状态,用于更新状态列的文字和颜色; finish_signal:任务完成信号,用于禁用操作按钮,清理资源。 3. 任务列表的动态管理 表格布局:用QTableWidget展示所有任务的详细信息,支持多列拉伸,适配不同窗口大小; 操作按钮:每个任务行都有独立的“开始/暂停/取消”按钮,通过lambda表达式传递任务索引,实现精准控制; 样式优化:根据任务状态设置不同的文字颜色(如完成绿色、失败红色),进度单元格根据进度设置背景色,提升视觉体验。 4. 异常处理机制 网络异常:捕获requests.exceptions.RequestException,包括超时、链接无效、服务器错误等; 文件异常:捕获文件读写时的权限错误、路径不存在等问题; 用户操作异常:避免重复点击开始按钮、暂停非运行中的任务等无效操作。 六、运行与测试步骤 环境准备:确保已安装PyQt5和requests库; 运行代码:将代码保存为multi_thread_downloader.py,执行命令:python multi_thread_downloader.py; 添加任务: 输入一个有效的下载链接(如Python官网的安装包:https://www.python.org/ftp/python/3.12.0/python-3.12.0-amd64.exe); 点击“浏览”选择保存路径(如D:/python.exe); 点击“添加任务”,任务将出现在列表中,状态为“等待中”; 测试功能: 点击“开始”按钮启动下载,观察进度、大小、速度是否实时更新; 点击“暂停”按钮,任务状态变为“已暂停”,再次点击恢复下载; 点击“取消”按钮,任务状态变为“已取消”,停止下载; 测试多任务下载:添加多个任务,点击“开始所有任务”,观察多个任务是否并行运行。 七、常见问题排查 1. 下载失败:提示“ConnectionError” 原因:下载链接无效、网络断开或服务器拒绝访问; 解决方案:检查链接是否正确,确保网络通畅,尝试更换下载链接(如使用公开的文件下载链接)。 2. 进度不更新:任务状态一直显示“下载中” 原因:文件过小,下载速度过快,进度直接跳到100%;或服务器不支持分块下载; 解决方案:尝试下载较大的文件(如几百MB的安装包),观察进度变化;检查response.headers是否包含content-length字段。 3. 暂停后继续下载,文件损坏 原因:未使用追加模式写入文件,暂停后重新下载覆盖了原有文件; 解决方案:确保文件打开模式为ab(追加二进制模式),而非wb(写入二进制模式)。 4. 窗口关闭后,下载线程仍在运行 原因:窗口关闭时未停止所有线程,子线程后台继续运行; 解决方案:在closeEvent中遍历所有任务,调用cancel()方法停止线程,并调用wait()等待线程结束。 八、功能拓展思路(进阶方向) 基于当前代码,可拓展以下实用功能,进一步提升工具的实用性: 断点续传优化:将已下载大小保存到本地配置文件,工具重启后可继续未完成的任务; 多线程分块下载:将一个文件分成多个块,每个块用一个子线程下载,提升下载速度; 任务排序与筛选:支持按状态(如“已完成”“下载中”)筛选任务,按下载速度排序; 下载限速:通过控制分块下载的间隔时间,实现下载速度限制; 文件校验:下载完成后,计算文件的MD5值,与官方提供的MD5对比,验证文件完整性。 总结 本次实战项目完美整合了阶段三的核心知识点——多线程编程、自定义信号、信号与槽绑定、事件处理,实现了一个功能完整的多线程下载工具。通过这个项目,你不仅掌握了PyQt5多线程开发的核心逻辑,还理解了“主线程管UI,子线程做任务”的分工原则,以及如何通过信号与槽实现线程间的安全通信。 下一章我们将进入阶段四的学习——PyQt5样式表(QSS),通过类似CSS的语法,给我们的界面进行美化,打造出高颜值的现代化GUI程序!如果在项目实操中遇到问题,或者有拓展功能的想法,欢迎在评论区留言讨论~ -
PyQt5多线程编程:解决界面卡顿的核心方案(附下载工具实战) 第13篇:PyQt5多线程编程:避免界面卡顿的核心方案(完整代码) 哈喽~ 欢迎来到PyQt5系列的第14篇!前面我们学了信号与槽和事件处理,但在实际开发中,你可能会遇到一个头疼的问题——当执行耗时操作(比如文件批量处理、网络请求、大数据计算)时,界面会直接卡死,无法点击、无法缩放,甚至被系统判定为“无响应”。 mkckots2.png图片 这一切的根源,在于PyQt5的单线程模型。今天我们就来学习多线程编程,通过QThread实现“主线程管界面,子线程做耗时任务”的分工,彻底解决界面卡顿问题!全程搭配完整可运行代码,帮你掌握多线程的核心逻辑和避坑技巧。 一、先搞懂:为什么单线程会导致界面卡顿? PyQt5的主线程(也叫UI线程) 有两个核心职责: 渲染界面:绘制窗口、控件、文字、图片等; 处理交互:响应按钮点击、鼠标移动、键盘输入等事件。 主线程是一个单线程循环——它会按顺序处理事件队列中的任务,一次只能做一件事。如果此时你在主线程中执行耗时操作(比如time.sleep(10)、读取1000个文件),主线程就会被“阻塞”,无法处理界面渲染和交互请求,表现为界面卡死。 多线程的核心解决方案 开启子线程(也叫工作线程),将所有耗时操作交给子线程执行,主线程只负责界面交互和数据展示。两者各司其职,互不干扰: 主线程:专注于界面渲染、用户交互、接收子线程传递的结果并更新界面; 子线程:专注于执行耗时操作,不涉及任何界面控件的直接操作。 核心铁律(必记!) PyQt5中,子线程绝对不能直接操作主线程的界面控件(比如在子线程中label.setText()、progressBar.setValue())。 违反这条规则,轻则界面卡顿,重则程序崩溃,且错误难以排查! 子线程与主线程的通信,必须通过信号与槽——子线程定义信号,耗时操作中发射信号传递数据;主线程绑定信号到槽函数,在槽函数中更新界面。 二、多线程的核心实现:继承QThread类(新手首选) PyQt5提供了QThread类实现多线程,继承QThread并重写run()方法是最常用、最容易理解的方式,适合新手入门。 核心步骤 自定义子线程类:继承QThread; 定义信号:用于子线程向主线程传递数据(如进度、结果、状态); 重写run()方法:将耗时操作写在run()中,这是子线程的执行入口; 主线程中使用子线程: 创建子线程实例; 绑定子线程的信号到主线程的槽函数; 调用start()方法启动子线程(注意:不是直接调用run()); 线程控制:通过标志位实现线程的安全停止(避免强制终止导致资源泄漏)。 三、实战1:基础多线程——进度条更新(解决卡顿核心案例) 我们以“模拟耗时任务+进度条更新”为例,演示多线程的核心用法。子线程负责计算进度,主线程负责更新进度条,全程界面流畅无卡顿。 完整代码 import sys import time from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QProgressBar, QPushButton, QLabel, QMessageBox ) from PyQt5.QtCore import QThread, pyqtSignal, Qt # -------------------------- 1. 自定义子线程类 -------------------------- class WorkThread(QThread): # 定义信号:传递进度值(int)和状态信息(str) progress_signal = pyqtSignal(int) status_signal = pyqtSignal(str) def __init__(self, total_steps=100): super().__init__() self.total_steps = total_steps # 总任务数 self.is_running = True # 线程运行标志位,用于安全停止 def run(self): """子线程执行的核心方法:耗时操作写在这里""" # 发射初始状态 self.status_signal.emit("任务开始...") for step in range(1, self.total_steps + 1): # 安全停止判断:如果标志位为False,终止循环 if not self.is_running: self.status_signal.emit("任务已停止!") break # 模拟耗时操作(比如文件下载、数据计算) time.sleep(0.05) # 发射进度信号(传递给主线程更新进度条) self.progress_signal.emit(step) # 每完成20%,发射一次状态信息 if step % 20 == 0: self.status_signal.emit(f"已完成{step}%...") # 任务正常完成(未被停止) if self.is_running: self.status_signal.emit("任务完成!") def stop(self): """安全停止线程:设置标志位为False""" self.is_running = False # -------------------------- 2. 主线程(界面类) -------------------------- class MainWindow(QWidget): def __init__(self): super().__init__() self.init_ui() self.thread = None # 子线程实例,初始化为None def init_ui(self): self.setWindowTitle("多线程进度条演示(无卡顿)") self.resize(400, 250) self.setStyleSheet("font-size: 14px;") # 布局 layout = QVBoxLayout() layout.setSpacing(20) layout.setContentsMargins(40, 30, 40, 30) # 进度条 self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) # 状态标签 self.status_label = QLabel("状态:未开始", alignment=Qt.AlignCenter) self.status_label.setStyleSheet("color: #2c3e50;") # 按钮 self.start_btn = QPushButton("开始任务") self.stop_btn = QPushButton("停止任务") self.stop_btn.setEnabled(False) # 初始禁用停止按钮 # 添加控件到布局 layout.addWidget(self.progress_bar) layout.addWidget(self.status_label) layout.addWidget(self.start_btn) layout.addWidget(self.stop_btn) self.setLayout(layout) # 绑定按钮信号 self.start_btn.clicked.connect(self.start_task) self.stop_btn.clicked.connect(self.stop_task) def start_task(self): """启动子线程""" # 1. 创建子线程实例 self.thread = WorkThread(total_steps=100) # 2. 绑定子线程的信号到主线程的槽函数 self.thread.progress_signal.connect(self.update_progress) self.thread.status_signal.connect(self.update_status) # 3. 绑定线程结束信号(可选:任务完成后启用按钮) self.thread.finished.connect(self.on_thread_finished) # 4. 启动子线程(关键:调用start(),会自动执行run()方法) self.thread.start() # 5. 更新界面状态 self.start_btn.setEnabled(False) self.stop_btn.setEnabled(True) self.status_label.setText("状态:任务执行中...") def stop_task(self): """停止子线程""" if self.thread and self.thread.isRunning(): self.thread.stop() self.stop_btn.setEnabled(False) self.status_label.setText("状态:任务停止中...") def update_progress(self, step): """主线程槽函数:更新进度条(接收子线程的进度信号)""" self.progress_bar.setValue(step) def update_status(self, msg): """主线程槽函数:更新状态标签(接收子线程的状态信号)""" self.status_label.setText(f"状态:{msg}") def on_thread_finished(self): """线程结束后恢复界面状态""" self.start_btn.setEnabled(True) self.stop_btn.setEnabled(False) # 释放线程资源 self.thread = None if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec_())核心亮点解析 线程安全通信:子线程WorkThread定义了progress_signal(传递进度)和status_signal(传递状态),主线程通过槽函数update_progress和update_status接收数据并更新界面,完全遵守“子线程不操作UI”的铁律。 安全停止机制:用is_running标志位控制run()方法中的循环,避免调用terminate()强制终止线程(强制终止会导致资源泄漏,比如文件未关闭、网络连接未断开)。 线程状态管理:通过thread.isRunning()判断线程是否正在运行,通过thread.finished信号监听线程结束事件,及时恢复界面状态和释放资源。 四、进阶用法:moveToThread实现多任务复用(适合复杂场景) 除了继承QThread,PyQt5还提供了moveToThread方法——将普通的工作类移动到子线程中执行。这种方式更灵活,适合一个子线程处理多个任务的场景。 核心步骤 定义工作类:继承QObject,定义耗时任务的方法和信号; 创建子线程:实例化QThread; 移动工作类到子线程:调用work_obj.moveToThread(thread); 绑定信号:将工作类的信号绑定到主线程槽函数,将主线程的信号绑定到工作类的任务方法; 启动线程:调用thread.start()。 完整代码:多任务复用的多线程 import sys import time from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QPushButton, QLabel ) from PyQt5.QtCore import QThread, QObject, pyqtSignal, pyqtSlot, Qt # -------------------------- 1. 定义工作类(包含多个耗时任务) -------------------------- class Worker(QObject): # 定义信号 result_signal = pyqtSignal(str) # 传递任务结果 finished_signal = pyqtSignal() # 任务完成信号 def __init__(self): super().__init__() self.is_running = True @pyqtSlot(str) # 标记为槽函数,接收主线程的任务指令 def do_task(self, task_name): """执行耗时任务:支持多个任务类型""" if not self.is_running: return self.result_signal.emit(f"开始执行任务:{task_name}") # 模拟不同任务的耗时操作 if task_name == "文件批量处理": for i in range(5): time.sleep(0.8) self.result_signal.emit(f"文件处理进度:{i+1}/5") elif task_name == "网络数据请求": for i in range(3): time.sleep(1) self.result_signal.emit(f"请求数据进度:{i+1}/3") elif task_name == "大数据计算": for i in range(4): time.sleep(0.5) self.result_signal.emit(f"计算进度:{i+1}/4") self.result_signal.emit(f"任务 {task_name} 执行完成!") self.finished_signal.emit() def stop(self): """停止所有任务""" self.is_running = False # -------------------------- 2. 主线程(界面类) -------------------------- class MoveToThreadDemo(QWidget): # 定义主线程的信号:用于向工作类发送任务指令 task_signal = pyqtSignal(str) def __init__(self): super().__init__() self.init_ui() self.init_thread() def init_ui(self): self.setWindowTitle("moveToThread 多任务演示") self.resize(400, 300) self.setStyleSheet("font-size: 14px;") layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(40, 30, 40, 30) # 按钮:三个不同的任务 self.btn1 = QPushButton("执行文件批量处理") self.btn2 = QPushButton("执行网络数据请求") self.btn3 = QPushButton("执行大数据计算") self.stop_btn = QPushButton("停止所有任务") # 结果标签 self.result_label = QLabel("等待执行任务...", alignment=Qt.AlignCenter) self.result_label.setStyleSheet("color: #e74c3c;") # 添加控件 for btn in [self.btn1, self.btn2, self.btn3, self.stop_btn]: layout.addWidget(btn) layout.addWidget(self.result_label) self.setLayout(layout) # 绑定按钮信号 self.btn1.clicked.connect(lambda: self.start_task("文件批量处理")) self.btn2.clicked.connect(lambda: self.start_task("网络数据请求")) self.btn3.clicked.connect(lambda: self.start_task("大数据计算")) self.stop_btn.clicked.connect(self.stop_all_tasks) def init_thread(self): """初始化子线程和工作类""" # 1. 创建子线程 self.thread = QThread() # 2. 创建工作类实例 self.worker = Worker() # 3. 将工作类移动到子线程 self.worker.moveToThread(self.thread) # 4. 绑定信号 # 主线程 → 工作类:发送任务指令 self.task_signal.connect(self.worker.do_task) # 工作类 → 主线程:传递任务结果 self.worker.result_signal.connect(self.update_result) # 工作类 → 主线程:任务完成 self.worker.finished_signal.connect(self.on_task_finished) # 线程结束时释放资源 self.thread.finished.connect(self.thread.deleteLater) # 5. 启动子线程 self.thread.start() def start_task(self, task_name): """发送任务指令到工作类""" if self.thread.isRunning(): self.worker.is_running = True self.task_signal.emit(task_name) # 禁用按钮避免重复点击 for btn in [self.btn1, self.btn2, self.btn3]: btn.setEnabled(False) def stop_all_tasks(self): """停止所有任务""" self.worker.stop() self.update_result("已停止所有任务!") def update_result(self, msg): """更新任务结果标签""" self.result_label.setText(msg) def on_task_finished(self): """任务完成后启用按钮""" for btn in [self.btn1, self.btn2, self.btn3]: btn.setEnabled(True) def closeEvent(self, event): """窗口关闭时安全停止线程""" self.worker.stop() self.thread.quit() self.thread.wait() event.accept() if __name__ == "__main__": app = QApplication(sys.argv) window = MoveToThreadDemo() window.show() sys.exit(app.exec_())两种多线程方式对比 实现方式优点缺点适用场景继承QThread代码简洁,逻辑清晰,新手易上手一个线程只能处理一个任务,复用性差简单耗时任务(如单一进度条更新、单个文件下载)moveToThread一个线程可处理多个任务,灵活性高,资源利用率高代码稍复杂,需要手动管理信号绑定复杂多任务场景(如同时处理文件、网络、计算任务)五、综合实战:多线程文件下载工具(贴近实际开发) 我们结合网络请求和进度条更新,实现一个简单的多线程文件下载工具。子线程负责下载文件并传递进度,主线程负责显示进度和下载状态。 完整代码(需安装requests库:pip install requests) import sys import requests from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QLineEdit, QPushButton, QProgressBar, QLabel, QMessageBox ) from PyQt5.QtCore import QThread, pyqtSignal, Qt, QFile, QIODevice # -------------------------- 子线程:文件下载线程 -------------------------- class DownloadThread(QThread): progress_signal = pyqtSignal(int) # 下载进度(0-100) status_signal = pyqtSignal(str) # 下载状态 def __init__(self, url, save_path): super().__init__() self.url = url self.save_path = save_path self.is_paused = False self.is_canceled = False self.chunk_size = 1024 * 1024 # 每次下载1MB def run(self): """下载文件的核心逻辑""" try: # 发送请求获取文件大小 response = requests.get(self.url, stream=True, timeout=10) total_size = int(response.headers.get("content-length", 0)) downloaded_size = 0 # 打开文件准备写入 file = QFile(self.save_path) if not file.open(QIODevice.WriteOnly): self.status_signal.emit(f"文件打开失败:{file.errorString()}") return self.status_signal.emit("开始下载...") # 分块下载 for chunk in response.iter_content(chunk_size=self.chunk_size): # 暂停判断 while self.is_paused: self.msleep(100) # 取消判断 if self.is_canceled: self.status_signal.emit("下载已取消") file.close() return # 写入文件 file.write(chunk) downloaded_size += len(chunk) # 计算进度并发射信号 if total_size > 0: progress = int((downloaded_size / total_size) * 100) self.progress_signal.emit(progress) file.close() if not self.is_canceled: self.status_signal.emit("下载完成!") self.progress_signal.emit(100) except requests.exceptions.RequestException as e: self.status_signal.emit(f"下载失败:{str(e)}") except Exception as e: self.status_signal.emit(f"未知错误:{str(e)}") def pause(self): """暂停下载""" self.is_paused = not self.is_paused status = "已暂停" if self.is_paused else "已继续" self.status_signal.emit(status) def cancel(self): """取消下载""" self.is_canceled = True # -------------------------- 主线程:下载工具界面 -------------------------- class DownloadTool(QWidget): def __init__(self): super().__init__() self.init_ui() self.download_thread = None def init_ui(self): self.setWindowTitle("多线程文件下载工具") self.resize(500, 300) self.setStyleSheet("font-size: 14px;") layout = QVBoxLayout() layout.setSpacing(20) layout.setContentsMargins(40, 30, 40, 30) # 输入框:下载链接 self.url_edit = QLineEdit() self.url_edit.setPlaceholderText("请输入文件下载链接(如图片、压缩包)") self.url_edit.setText("https://www.python.org/static/img/python-logo.png") # 输入框:保存路径 self.path_edit = QLineEdit() self.path_edit.setPlaceholderText("请输入保存路径(如 D:/python.png)") self.path_edit.setText("python.png") # 进度条 self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) # 状态标签 self.status_label = QLabel("状态:未开始", alignment=Qt.AlignCenter) # 按钮 self.start_btn = QPushButton("开始下载") self.pause_btn = QPushButton("暂停/继续") self.cancel_btn = QPushButton("取消下载") self.pause_btn.setEnabled(False) self.cancel_btn.setEnabled(False) # 添加控件 layout.addWidget(QLabel("下载链接:")) layout.addWidget(self.url_edit) layout.addWidget(QLabel("保存路径:")) layout.addWidget(self.path_edit) layout.addWidget(self.progress_bar) layout.addWidget(self.status_label) layout.addWidget(self.start_btn) layout.addWidget(self.pause_btn) layout.addWidget(self.cancel_btn) self.setLayout(layout) # 绑定信号 self.start_btn.clicked.connect(self.start_download) self.pause_btn.clicked.connect(self.pause_download) self.cancel_btn.clicked.connect(self.cancel_download) def start_download(self): """启动下载线程""" url = self.url_edit.text().strip() save_path = self.path_edit.text().strip() if not url or not save_path: QMessageBox.warning(self, "提示", "请输入下载链接和保存路径!") return # 创建下载线程 self.download_thread = DownloadThread(url, save_path) # 绑定信号 self.download_thread.progress_signal.connect(self.update_progress) self.download_thread.status_signal.connect(self.update_status) self.download_thread.finished.connect(self.on_download_finished) # 启动线程 self.download_thread.start() # 更新界面状态 self.start_btn.setEnabled(False) self.pause_btn.setEnabled(True) self.cancel_btn.setEnabled(True) def pause_download(self): """暂停/继续下载""" if self.download_thread and self.download_thread.isRunning(): self.download_thread.pause() def cancel_download(self): """取消下载""" if self.download_thread and self.download_thread.isRunning(): self.download_thread.cancel() self.pause_btn.setEnabled(False) def update_progress(self, progress): """更新下载进度""" self.progress_bar.setValue(progress) def update_status(self, msg): """更新下载状态""" self.status_label.setText(f"状态:{msg}") def on_download_finished(self): """下载完成后恢复界面状态""" self.start_btn.setEnabled(True) self.pause_btn.setEnabled(False) self.cancel_btn.setEnabled(False) self.download_thread = None if __name__ == "__main__": app = QApplication(sys.argv) window = DownloadTool() window.show() sys.exit(app.exec_())核心功能说明 分块下载:通过response.iter_content()分块读取文件,避免一次性加载大文件到内存; 暂停/继续功能:用is_paused标志位实现暂停,通过msleep(100)减少CPU占用; 异常处理:捕获网络请求异常和文件操作异常,通过status_signal反馈给用户; 界面状态管理:下载过程中禁用开始按钮,避免重复下载,下载完成后恢复状态。 六、多线程开发的常见问题与避坑指南 1. 子线程直接操作UI导致崩溃 问题原因:违反“子线程不能操作UI”的铁律; 解决方案:严格通过信号与槽实现子线程到主线程的通信,所有UI更新操作必须在主线程的槽函数中执行。 2. 线程启动后被垃圾回收 问题原因:子线程实例是局部变量,函数执行完后被Python垃圾回收机制销毁; 解决方案:将子线程实例设为主线程类的属性(如self.thread = DownloadThread()),确保生命周期与主线程一致。 3. 线程无法停止或停止后资源泄漏 问题原因:使用terminate()强制终止线程,或未正确处理标志位; 解决方案:用标志位(如is_running、is_canceled)控制run()方法中的循环,线程结束前确保关闭文件、断开网络连接等资源释放操作。 4. 多个线程同时操作同一数据导致混乱 问题原因:多线程共享数据时,可能出现“竞争条件”(比如一个线程写数据,另一个线程读数据); 解决方案:使用QMutex(互斥锁)保护共享数据,确保同一时间只有一个线程能访问数据。 总结 多线程核心目的:将耗时操作从主线程分离到子线程,实现“界面流畅+任务并行”; 核心铁律:子线程绝对不能直接操作UI控件,必须通过信号与槽通信; 两种实现方式: 继承QThread:适合简单单任务场景,新手首选; moveToThread:适合复杂多任务场景,灵活性高; 安全要点:用标志位实现线程的安全停止,避免强制终止;将线程实例设为类属性,防止被垃圾回收。 下一章我们将学习PyQt5样式表(QSS)——通过类似CSS的语法,给你的界面穿上“漂亮的衣服”,实现现代化的界面美化!如果在多线程开发中遇到卡顿、线程停止、资源泄漏等问题,欢迎在评论区留言讨论~ -
PyQt5事件处理:重写鼠标/键盘/窗口事件函数(附绘图实战) 第13篇:PyQt5事件处理:重写事件函数实现灵活交互(完整代码) 哈喽~ 欢迎来到PyQt5系列的第13篇!前面我们学了信号与槽和自定义信号,这两种方式能解决大部分交互需求,但它们是基于控件预设的“高级通知”(比如按钮点击、输入框文本变化)。而在实际开发中,我们还需要捕获更底层的用户操作——比如鼠标点击的具体位置、键盘按下的快捷键、窗口缩放的实时大小等,这就需要用到 PyQt5事件处理。 mk9obcxb.png图片 今天我们就来学习如何通过重写事件函数,捕获并处理鼠标、键盘、窗口等底层事件,实现更灵活的界面交互逻辑,全程搭配完整可运行代码,新手也能轻松掌握! 一、先搞懂:事件与信号的区别(核心概念) 很多同学会混淆事件(Event) 和信号(Signal),其实它们是两个不同层级的概念,核心区别如下: 对比维度事件(Event)信号(Signal)本质底层的输入/系统消息(如鼠标移动、键盘按下、窗口缩放)控件发出的高级通知(如按钮点击、文本变化)触发源操作系统/用户直接操作(如鼠标点一下、按键盘)控件状态变化(如按钮被点击后发出clicked信号)处理方式重写控件的事件函数(如mousePressEvent)绑定信号到槽函数(如btn.clicked.connect(func))灵活性极高,可捕获最细节的操作(如鼠标坐标、按键类型)中等,只能响应控件预设的信号关系信号是基于事件封装的(比如按钮的clicked信号,底层就是鼠标点击事件)-举个例子:点击按钮时,操作系统会先发送鼠标点击事件给按钮,按钮接收到事件后,会触发clicked信号,最终执行我们绑定的槽函数。 二、事件处理的核心:重写事件函数 PyQt5中所有控件都继承自QWidget,而QWidget内置了大量事件函数(如mousePressEvent、keyPressEvent)。我们只需在自定义控件/窗口类中重写这些函数,就能捕获并处理对应的事件。 核心规则 事件函数是固定名称的(比如处理鼠标点击的函数必须叫mousePressEvent),不能自定义名称; 事件函数的参数固定,第一个参数是事件对象(如QMouseEvent、QKeyEvent),包含事件的详细信息; 重写事件函数时,若需要保留控件的原有行为,需调用父类的同名事件函数(如super().mousePressEvent(event))。 三、实战1:鼠标事件处理(最常用) 鼠标事件是最常见的底层事件,包括按下、释放、双击、移动四种,对应的事件函数如下: 事件函数作用事件对象核心方法mousePressEvent(event)鼠标按下时触发QMouseEventevent.pos():获取鼠标位置;event.button():判断鼠标键(左/右/中)mouseReleaseEvent(event)鼠标释放时触发QMouseEvent同上mouseDoubleClickEvent(event)鼠标双击时触发QMouseEvent同上mouseMoveEvent(event)鼠标移动时触发QMouseEvent同上完整代码:鼠标事件捕获演示 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont class MouseEventDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("鼠标事件处理演示") self.resize(500, 400) self.setMouseTracking(True) # 关键:开启鼠标追踪(不按下也能捕获移动事件) # 布局与标签(显示事件信息) layout = QVBoxLayout() self.info_label = QLabel("鼠标状态:未操作\n位置:(0, 0)\n按键:无", alignment=Qt.AlignCenter) self.info_label.setFont(QFont("微软雅黑", 12)) layout.addWidget(self.info_label) self.setLayout(layout) # ---------- 1. 鼠标按下事件 ---------- def mousePressEvent(self, event): # 获取鼠标位置(相对于当前窗口) x = event.x() y = event.y() # 判断按下的鼠标键 if event.button() == Qt.LeftButton: btn_text = "左键" elif event.button() == Qt.RightButton: btn_text = "右键" elif event.button() == Qt.MiddleButton: btn_text = "中键" else: btn_text = "未知" # 更新标签信息 self.info_label.setText(f"鼠标状态:按下\n位置:( , {y})\n按键:{btn_text}") # 保留父类的原有行为(可选,比如让窗口能被拖动) super().mousePressEvent(event) # ---------- 2. 鼠标释放事件 ---------- def mouseReleaseEvent(self, event): x = event.x() y = event.y() self.info_label.setText(f"鼠标状态:释放\n位置:( , {y})\n按键:无") super().mouseReleaseEvent(event) # ---------- 3. 鼠标双击事件 ---------- def mouseDoubleClickEvent(self, event): x = event.x() y = event.y() self.info_label.setText(f"鼠标状态:双击\n位置:( , {y})\n按键:左键") # 双击窗口标题栏可以最大化,这里保留该行为 super().mouseDoubleClickEvent(event) # ---------- 4. 鼠标移动事件 ---------- def mouseMoveEvent(self, event): x = event.x() y = event.y() # 实时显示鼠标位置(需开启setMouseTracking(True)) self.info_label.setText(f"鼠标状态:移动\n位置:( , {y})\n按键:无") super().mouseMoveEvent(event) if __name__ == "__main__": app = QApplication(sys.argv) window = MouseEventDemo() window.show() sys.exit(app.exec_())关键要点 鼠标追踪:默认情况下,mouseMoveEvent只有在鼠标按下时才会触发。调用self.setMouseTracking(True)后,不按下鼠标也能捕获移动事件; 鼠标位置:event.x()/event.y()获取的是相对于当前窗口的坐标,event.globalX()/event.globalY()获取的是相对于屏幕的坐标; 鼠标键判断:通过event.button()配合Qt.LeftButton/Qt.RightButton/Qt.MiddleButton判断按下的按键。 四、实战2:键盘事件处理(快捷键实现) 键盘事件用于捕获按键操作,比如实现快捷键(如Ctrl+S保存、ESC关闭窗口),核心事件函数是keyPressEvent和keyReleaseEvent。 事件函数作用事件对象核心方法keyPressEvent(event)按键按下时触发QKeyEventevent.key():获取按键;event.modifiers():判断组合键(如Ctrl/Shift)keyReleaseEvent(event)按键释放时触发QKeyEvent同上完整代码:键盘事件与快捷键实现 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QMessageBox from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont class KeyEventDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("键盘事件与快捷键演示") self.resize(400, 300) layout = QVBoxLayout() self.info_label = QLabel(""" 快捷键说明: 1. 按ESC键:关闭窗口 2. 按Ctrl+S:触发保存提示 3. 按方向键:移动标签位置 """, alignment=Qt.AlignCenter) self.info_label.setFont(QFont("微软雅黑", 11)) # 可移动的标签 self.move_label = QLabel("我是可移动的标签", alignment=Qt.AlignCenter) self.move_label.setStyleSheet("color: #e74c3c; font-size: 14px;") self.move_label.setFixedSize(150, 30) layout.addWidget(self.info_label) layout.addWidget(self.move_label) self.setLayout(layout) # 让窗口获得焦点,否则无法捕获键盘事件 self.setFocusPolicy(Qt.StrongFocus) # ---------- 键盘按下事件 ---------- def keyPressEvent(self, event): # 获取当前标签位置 x = self.move_label.x() y = self.move_label.y() # 1. 处理方向键:移动标签 if event.key() == Qt.Key_Left: self.move_label.move(x - 10, y) elif event.key() == Qt.Key_Right: self.move_label.move(x + 10, y) elif event.key() == Qt.Key_Up: self.move_label.move(x, y - 10) elif event.key() == Qt.Key_Down: self.move_label.move(x, y + 10) # 2. 处理ESC键:关闭窗口 elif event.key() == Qt.Key_Escape: self.close() # 3. 处理组合键:Ctrl+S elif event.key() == Qt.Key_S and event.modifiers() == Qt.ControlModifier: QMessageBox.information(self, "快捷键触发", "Ctrl+S:模拟保存成功!") # 保留父类的原有行为 super().keyPressEvent(event) if __name__ == "__main__": app = QApplication(sys.argv) window = KeyEventDemo() window.show() sys.exit(app.exec_())关键要点 焦点问题:控件必须获得焦点才能捕获键盘事件。调用self.setFocusPolicy(Qt.StrongFocus)让窗口强制获得焦点; 组合键判断:通过event.modifiers()判断组合键,常用的有Qt.ControlModifier(Ctrl键)、Qt.ShiftModifier(Shift键)、Qt.AltModifier(Alt键); 常用按键常量:Qt.Key_Escape(ESC)、Qt.Key_Enter(回车)、Qt.Key_A-Qt.Key_Z(字母键)。 五、实战3:窗口事件处理(关闭/缩放/移动) 窗口事件用于捕获窗口的状态变化,比如关闭、缩放、移动,最常用的是closeEvent(关闭窗口时触发,用于确认是否关闭)。 事件函数作用事件对象核心方法closeEvent(event)窗口关闭时触发QCloseEventevent.accept():允许关闭;event.ignore():阻止关闭resizeEvent(event)窗口缩放时触发QResizeEventevent.size():获取新大小;event.oldSize():获取旧大小moveEvent(event)窗口移动时触发QMoveEventevent.pos():获取新位置;event.oldPos():获取旧位置完整代码:窗口事件处理演示 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel, QMessageBox from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont class WindowEventDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("窗口事件处理演示") self.resize(400, 300) layout = QVBoxLayout() self.info_label = QLabel("窗口状态:正常\n大小:400×300\n位置:(0, 0)", alignment=Qt.AlignCenter) self.info_label.setFont(QFont("微软雅黑", 12)) layout.addWidget(self.info_label) self.setLayout(layout) # ---------- 1. 窗口关闭事件(最常用) ---------- def closeEvent(self, event): # 弹出确认框,询问是否关闭 reply = QMessageBox.question( self, "关闭确认", "确定要关闭窗口吗?未保存的内容可能会丢失!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: event.accept() # 允许关闭窗口 else: event.ignore() # 阻止关闭窗口 # ---------- 2. 窗口缩放事件 ---------- def resizeEvent(self, event): # 获取窗口新大小 new_size = event.size() width = new_size.width() height = new_size.height() # 更新状态信息 self.info_label.setText(f"窗口状态:缩放\n大小:{width}×{height}\n位置:({self.x()}, {self.y()})") super().resizeEvent(event) # ---------- 3. 窗口移动事件 ---------- def moveEvent(self, event): # 获取窗口新位置 new_pos = event.pos() x = new_pos.x() y = new_pos.y() # 更新状态信息 self.info_label.setText(f"窗口状态:移动\n大小:{self.width()}×{self.height()}\n位置:( , {y})") super().moveEvent(event) if __name__ == "__main__": app = QApplication(sys.argv) window = WindowEventDemo() window.show() sys.exit(app.exec_())关键要点 关闭事件控制:closeEvent中,event.accept()允许窗口关闭,event.ignore()阻止关闭。这是实现“关闭确认”的核心方法; 大小/位置获取:resizeEvent中用event.size()获取新大小,moveEvent中用event.pos()获取新位置; 父类方法调用:重写窗口事件时,必须调用父类的同名方法,否则窗口的正常功能会被破坏(比如无法缩放、移动)。 六、综合案例:简易绘图工具(鼠标事件实战) 结合鼠标的按下、移动、释放事件,实现一个简易的绘图工具——按下鼠标拖动时绘制线条,释放鼠标时结束绘制。 完整代码:简易绘图工具 import sys from PyQt5.QtWidgets import QApplication, QWidget from PyQt5.QtCore import Qt, QPoint from PyQt5.QtGui import QPainter, QPen class PaintTool(QWidget): def __init__(self): super().__init__() self.init_ui() # 绘图相关变量 self.is_drawing = False # 是否正在绘图 self.start_point = QPoint() # 绘图起始点 self.end_point = QPoint() # 绘图结束点 def init_ui(self): self.setWindowTitle("简易绘图工具(鼠标拖动绘制线条)") self.resize(600, 500) self.setMouseTracking(True) # ---------- 鼠标按下:开始绘图 ---------- def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.is_drawing = True self.start_point = event.pos() # 记录起始点 self.end_point = self.start_point # 初始结束点等于起始点 super().mousePressEvent(event) # ---------- 鼠标移动:更新绘图 ---------- def mouseMoveEvent(self, event): if self.is_drawing: self.end_point = event.pos() # 更新结束点 self.update() # 触发重绘(调用paintEvent) super().mouseMoveEvent(event) # ---------- 鼠标释放:结束绘图 ---------- def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton: self.is_drawing = False self.end_point = event.pos() self.update() # 绘制最后一条线 super().mouseReleaseEvent(event) # ---------- 重绘事件:实际绘制线条 ---------- def paintEvent(self, event): # QPainter是PyQt5的绘图工具 painter = QPainter(self) # 设置画笔样式(颜色、宽度、线条类型) pen = QPen(Qt.red, 3, Qt.SolidLine) painter.setPen(pen) # 绘制线条:从起始点到结束点 painter.drawLine(self.start_point, self.end_point) if __name__ == "__main__": app = QApplication(sys.argv) window = PaintTool() window.show() sys.exit(app.exec_())核心逻辑 绘图状态控制:用is_drawing标记是否正在绘图,鼠标按下时设为True,释放时设为False; 重绘触发:self.update()会强制触发paintEvent方法,实现实时绘图; QPainter绘图:QPainter是PyQt5的绘图核心类,drawLine方法用于绘制线条,还支持绘制矩形、圆形等图形。 七、常见问题排查 1. 鼠标移动事件不触发 问题原因:未开启鼠标追踪,默认只有鼠标按下时才会触发mouseMoveEvent; 解决方案:调用self.setMouseTracking(True)开启鼠标追踪。 2. 键盘事件不响应 问题原因1:控件没有获得焦点; 解决方案:调用self.setFocusPolicy(Qt.StrongFocus)让控件获得焦点; 问题原因2:重写事件函数时忘记调用父类方法; 解决方案:在函数末尾添加super().keyPressEvent(event)。 3. 窗口无法关闭/缩放 问题原因:重写closeEvent/resizeEvent时忘记调用父类方法,破坏了窗口的原有功能; 解决方案:必须调用super().closeEvent(event)或super().resizeEvent(event)。 4. 绘图时线条闪烁 问题原因:每次update()都会重绘整个窗口,导致闪烁; 解决方案:开启双缓冲绘图(PyQt5默认开启,无需手动设置),或只重绘需要更新的区域。 总结 事件与信号的区别:事件是底层操作,信号是控件的高级通知,信号基于事件封装; 事件处理核心:重写固定名称的事件函数,调用父类方法保留原有功能; 常用事件: 鼠标事件:mousePressEvent/mouseMoveEvent(需开启追踪); 键盘事件:keyPressEvent(需获得焦点); 窗口事件:closeEvent(用于关闭确认); 实战价值:事件处理能实现信号与槽无法覆盖的交互(如绘图、快捷键、窗口状态监控)。 下一章我们将学习PyQt5多线程编程——解决耗时操作导致界面卡顿的问题,这是开发高性能GUI程序的必备技能!如果在事件处理中遇到绘图、快捷键实现的问题,欢迎在评论区留言讨论~ -
PyQt5自定义信号:跨窗口通信与多线程进度传递(完整代码) 第12篇:PyQt5自定义信号:满足复杂交互与跨组件通信需求(完整代码) 哈喽~ 欢迎来到PyQt5系列的第12篇!上一章我们吃透了内置信号与槽的基础用法和进阶技巧,但在实际开发中,内置信号往往无法满足复杂场景(比如两个窗口之间传递数据、自定义控件的状态变化、多线程间的进度通知)。今天我们就来学习自定义信号——PyQt5中实现跨组件、跨线程通信的核心武器,全程搭配完整可运行代码,帮你彻底掌握自定义信号的定义、发射与绑定逻辑! mk7oiwng.png图片 一、先明确:自定义信号的核心使用场景 当以下场景出现时,内置信号就不够用了,必须用自定义信号: 跨窗口通信:主窗口和子窗口之间传递数据(如子窗口输入的内容同步到主窗口); 自定义控件:开发自己的控件(如自定义进度条),需要向外发送状态变化信号; 多线程通信:子线程不能直接操作主界面,需通过自定义信号将数据传递给主线程; 复杂业务逻辑:业务状态变化(如支付成功、数据加载完成)需要触发多个槽函数响应。 自定义信号的核心规则(必记!) 自定义信号必须定义在继承自QObject的类中(PyQt5所有控件都继承了QObject,所以自定义窗口类也可以); 自定义信号是类属性,需用pyqtSignal()方法创建,不能在__init__中定义; 信号通过emit()方法发射,发射时的参数必须与信号定义的参数类型一致; 自定义信号同样支持connect()绑定槽函数、disconnect()断开绑定。 二、自定义信号的基础用法:定义、发射与绑定 1. 无参数自定义信号(基础入门) 先从最简单的无参数自定义信号入手,掌握“定义→发射→绑定”的核心流程。 完整代码:无参数自定义信号 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel from PyQt5.QtCore import QObject, pyqtSignal # 自定义信号的载体类(必须继承QObject) # 注意:如果是自定义窗口类,本身继承QWidget(已继承QObject),可直接在窗口类中定义信号 class MySignal(QObject): # 定义无参数的自定义信号,类属性 custom_signal = pyqtSignal() class SignalDemo(QWidget): def __init__(self): super().__init__() self.init_ui() self.init_signal() def init_ui(self): self.setWindowTitle("无参数自定义信号演示") self.resize(350, 200) layout = QVBoxLayout() self.label = QLabel("信号未触发", alignment=1) self.trigger_btn = QPushButton("点击发射自定义信号") layout.addWidget(self.label) layout.addWidget(self.trigger_btn) self.setLayout(layout) def init_signal(self): # 1. 创建自定义信号的实例 self.my_signal = MySignal() # 2. 绑定自定义信号到槽函数 self.my_signal.custom_signal.connect(self.on_custom_signal_trigger) # 3. 绑定按钮点击信号,触发自定义信号的发射 self.trigger_btn.clicked.connect(self.emit_custom_signal) def emit_custom_signal(self): """发射自定义信号""" print("按钮点击,准备发射自定义信号...") # 核心方法:emit() 发射信号 self.my_signal.custom_signal.emit() def on_custom_signal_trigger(self): """自定义信号的槽函数""" self.label.setText("自定义信号触发成功!") print("自定义信号槽函数执行完成") if __name__ == "__main__": app = QApplication(sys.argv) window = SignalDemo() window.show() sys.exit(app.exec_())核心步骤解析 定义信号:在继承QObject的类中,用pyqtSignal()创建类属性custom_signal,这就是自定义信号; 创建信号实例:在窗口类中实例化MySignal,得到信号载体; 绑定槽函数:用custom_signal.connect(槽函数)将信号与响应逻辑绑定; 发射信号:通过custom_signal.emit()发射信号,触发槽函数执行。 2. 带参数的自定义信号(高频实战) 大多数场景下,信号需要传递数据(如文本、数值、对象),这就需要定义带参数的自定义信号。pyqtSignal()支持指定参数类型(如int、str、tuple等),发射时必须传递对应类型的参数。 完整代码:带参数的自定义信号 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel, QLineEdit from PyQt5.QtCore import pyqtSignal # 窗口类已继承QObject,可直接定义信号 class ParamSignalDemo(QWidget): # 定义带参数的自定义信号:支持多种参数类型 # 格式:pyqtSignal(参数类型1, 参数类型2, ...) text_signal = pyqtSignal(str) # 传递字符串 num_signal = pyqtSignal(int, str) # 传递整数+字符串 dict_signal = pyqtSignal(dict) # 传递字典 def __init__(self): super().__init__() self.init_ui() self.bind_signals() def init_ui(self): self.setWindowTitle("带参数自定义信号演示") self.resize(400, 300) layout = QVBoxLayout() self.edit = QLineEdit() self.edit.setPlaceholderText("输入文本,点击按钮发射信号") self.label1 = QLabel("字符串信号:未触发", alignment=1) self.label2 = QLabel("整数+字符串信号:未触发", alignment=1) self.label3 = QLabel("字典信号:未触发", alignment=1) self.btn = QPushButton("发射所有带参数信号") layout.addWidget(self.edit) layout.addWidget(self.label1) layout.addWidget(self.label2) layout.addWidget(self.label3) layout.addWidget(self.btn) self.setLayout(layout) def bind_signals(self): """绑定自定义信号到槽函数""" self.text_signal.connect(self.on_text_signal) self.num_signal.connect(self.on_num_signal) self.dict_signal.connect(self.on_dict_signal) self.btn.clicked.connect(self.emit_param_signals) def emit_param_signals(self): """发射带参数的自定义信号""" input_text = self.edit.text().strip() or "默认文本" # 发射信号:参数类型必须与定义一致 self.text_signal.emit(input_text) # 传递字符串 self.num_signal.emit(2026, input_text) # 传递整数+字符串 self.dict_signal.emit({"name": input_text, "year": 2026}) # 传递字典 # 对应不同参数的槽函数 def on_text_signal(self, text): self.label1.setText(f"字符串信号:{text}") def on_num_signal(self, num, text): self.label2.setText(f"整数+字符串信号:{num} | {text}") def on_dict_signal(self, data): self.label3.setText(f"字典信号:{data}") if __name__ == "__main__": app = QApplication(sys.argv) window = ParamSignalDemo() window.show() sys.exit(app.exec_())关键要点 信号参数定义:pyqtSignal(str)表示信号传递字符串,pyqtSignal(int, str)表示传递两个参数(整数+字符串),支持Python基本数据类型和自定义对象; 发射参数匹配:emit()的参数数量和类型必须与信号定义完全一致,否则会触发TypeError; 槽函数接收参数:槽函数的参数数量要与信号传递的参数数量一致,顺序对应。 三、实战案例1:主窗口与子窗口的跨窗口通信 跨窗口通信是自定义信号最常用的场景之一。比如点击主窗口按钮弹出子窗口,子窗口输入内容后,通过自定义信号将数据传递回主窗口并显示。 步骤1:定义子窗口类(含自定义信号) 子窗口负责接收用户输入,输入完成后发射自定义信号传递数据。 # 子窗口类 from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QPushButton, QLabel from PyQt5.QtCore import pyqtSignal class ChildWindow(QDialog): # 定义自定义信号:传递用户输入的文本 child_signal = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.init_ui() def init_ui(self): self.setWindowTitle("子窗口(输入数据)") self.setFixedSize(300, 200) layout = QVBoxLayout() self.edit = QLineEdit() self.edit.setPlaceholderText("请输入要传递的内容") self.confirm_btn = QPushButton("确认并传递给主窗口") layout.addWidget(self.edit) layout.addWidget(self.confirm_btn) self.setLayout(layout) # 绑定按钮点击信号,发射自定义信号 self.confirm_btn.clicked.connect(self.on_confirm) def on_confirm(self): input_text = self.edit.text().strip() if input_text: # 发射信号,传递输入内容 self.child_signal.emit(input_text) self.close() # 关闭子窗口 else: QLabel("请输入内容!").show()步骤2:定义主窗口类(接收子窗口信号) 主窗口点击按钮弹出子窗口,绑定子窗口的自定义信号,接收数据并显示。 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel from PyQt5.QtCore import Qt class MainWindow(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("主窗口(接收子窗口数据)") self.resize(400, 250) layout = QVBoxLayout() self.result_label = QLabel("子窗口传递的数据:无", alignment=Qt.AlignCenter) self.result_label.setStyleSheet("font-size: 16px; color: #2ecc71;") self.open_child_btn = QPushButton("打开子窗口") layout.addWidget(self.result_label) layout.addWidget(self.open_child_btn) self.setLayout(layout) # 绑定按钮点击信号,打开子窗口 self.open_child_btn.clicked.connect(self.open_child_window) def open_child_window(self): # 创建子窗口实例 self.child_win = ChildWindow(self) # 绑定子窗口的自定义信号到槽函数 self.child_win.child_signal.connect(self.on_child_data_received) # 显示子窗口(模态) self.child_win.exec_() def on_child_data_received(self, data): """接收子窗口传递的数据""" self.result_label.setText(f"子窗口传递的数据:{data}") if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec_())案例核心亮点 子窗口信号定义:子窗口类中定义child_signal = pyqtSignal(str),用于传递输入文本; 跨窗口信号绑定:主窗口创建子窗口后,通过child_win.child_signal.connect()绑定槽函数,实现数据监听; 模态窗口通信:子窗口用exec_()显示为模态窗口,用户操作完子窗口后,数据同步回主窗口。 四、实战案例2:多线程中用自定义信号传递进度(避免界面卡顿) PyQt5中子线程不能直接操作主界面控件,否则会导致界面卡顿甚至崩溃。正确的做法是:子线程执行耗时任务,通过自定义信号将进度传递给主线程,主线程更新界面。 完整代码:多线程进度传递 import sys import time from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QProgressBar, QLabel from PyQt5.QtCore import pyqtSignal, QThread, Qt # 自定义子线程类:执行耗时任务,发射进度信号 class WorkThread(QThread): # 定义自定义信号:传递进度值(整数) progress_signal = pyqtSignal(int) # 定义任务完成信号 finish_signal = pyqtSignal(str) def __init__(self, total_steps=100): super().__init__() self.total_steps = total_steps self.is_running = True def run(self): """线程执行的核心方法:耗时任务""" for step in range(1, self.total_steps + 1): if not self.is_running: break # 模拟耗时操作(如文件下载、数据处理) time.sleep(0.05) # 发射进度信号 self.progress_signal.emit(step) # 任务完成,发射完成信号 self.finish_signal.emit("任务执行完成!" if self.is_running else "任务被取消!") def stop(self): """停止线程""" self.is_running = False # 主窗口类:显示进度条,控制线程 class ThreadSignalDemo(QWidget): def __init__(self): super().__init__() self.init_ui() self.thread = None # 线程实例 def init_ui(self): self.setWindowTitle("多线程自定义信号传递进度") self.resize(400, 250) layout = QVBoxLayout() self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.status_label = QLabel("状态:未开始", alignment=Qt.AlignCenter) self.start_btn = QPushButton("开始任务") self.stop_btn = QPushButton("停止任务") self.stop_btn.setEnabled(False) layout.addWidget(self.progress_bar) layout.addWidget(self.status_label) layout.addWidget(self.start_btn) layout.addWidget(self.stop_btn) self.setLayout(layout) # 绑定按钮信号 self.start_btn.clicked.connect(self.start_task) self.stop_btn.clicked.connect(self.stop_task) def start_task(self): """启动子线程""" self.start_btn.setEnabled(False) self.stop_btn.setEnabled(True) self.status_label.setText("状态:任务执行中...") # 创建线程实例 self.thread = WorkThread(total_steps=100) # 绑定线程的自定义信号到槽函数 self.thread.progress_signal.connect(self.update_progress) self.thread.finish_signal.connect(self.on_task_finish) # 启动线程 self.thread.start() def stop_task(self): """停止子线程""" if self.thread and self.thread.isRunning(): self.thread.stop() self.status_label.setText("状态:任务停止中...") def update_progress(self, step): """更新进度条(主线程执行)""" self.progress_bar.setValue(step) def on_task_finish(self, msg): """任务完成回调""" self.status_label.setText(f"状态:{msg}") self.progress_bar.setValue(0) self.start_btn.setEnabled(True) self.stop_btn.setEnabled(False) # 释放线程资源 self.thread = None if __name__ == "__main__": app = QApplication(sys.argv) window = ThreadSignalDemo() window.show() sys.exit(app.exec_())案例核心知识点 线程信号定义:子线程类WorkThread中定义progress_signal = pyqtSignal(int),用于传递进度值; 线程安全通信:子线程在run()方法中执行耗时任务,通过emit()发射进度,主线程的槽函数update_progress()更新进度条,避免了子线程直接操作界面; 线程控制:通过is_running标志控制线程是否继续执行,stop()方法安全停止线程,避免强制终止导致的资源泄漏。 五、自定义信号的高级用法:重载信号与信号断开 1. 重载信号(支持多种参数类型) 重载信号指同一个信号名支持多种参数类型,比如data_signal既可以传递int,也可以传递str。定义时用元组包裹不同的参数类型组合。 完整代码:重载信号 import sys from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel from PyQt5.QtCore import pyqtSignal class OverloadSignalDemo(QWidget): # 定义重载信号:支持两种参数类型(int)或(str) data_signal = pyqtSignal([int], [str]) def __init__(self): super().__init__() self.init_ui() self.bind_signals() def init_ui(self): self.setWindowTitle("重载自定义信号演示") self.resize(350, 200) layout = QVBoxLayout() self.label = QLabel("信号未触发", alignment=1) self.btn1 = QPushButton("发射整数信号") self.btn2 = QPushButton("发射字符串信号") layout.addWidget(self.label) layout.addWidget(self.btn1) layout.addWidget(self.btn2) self.setLayout(layout) def bind_signals(self): # 绑定重载信号:指定参数类型,对应不同槽函数 self.data_signal[int].connect(self.on_int_signal) self.data_signal[str].connect(self.on_str_signal) self.btn1.clicked.connect(lambda: self.data_signal[int].emit(2026)) self.btn2.clicked.connect(lambda: self.data_signal[str].emit("重载信号演示")) def on_int_signal(self, num): self.label.setText(f"整数信号:{num}") def on_str_signal(self, text): self.label.setText(f"字符串信号:{text}") if __name__ == "__main__": app = QApplication(sys.argv) window = OverloadSignalDemo() window.show() sys.exit(app.exec_())2. 自定义信号的断开(disconnect) 和内置信号一样,自定义信号也可以用disconnect()方法断开绑定,适用于动态控制信号是否生效的场景。 关键代码片段 # 断开指定槽函数的绑定 self.data_signal.disconnect(self.on_int_signal) # 断开该信号的所有绑定槽函数 self.data_signal.disconnect()六、自定义信号常见问题排查 1. 信号定义后无法发射(最常见!) 问题原因:自定义信号定义在__init__方法中,而非类属性; 解决方案:信号必须是类属性,直接在类中定义(如class MyClass(QObject): signal = pyqtSignal()),不能在__init__里用self.signal = pyqtSignal()。 2. 发射信号时报错TypeError: emit() takes 1 positional argument but 2 were given 问题原因:发射信号的参数数量与信号定义的不一致; 解决方案:检查pyqtSignal()的参数类型和emit()的参数数量,确保完全匹配。 3. 子线程发射信号,主线程槽函数不执行 问题原因1:线程实例被垃圾回收(比如线程是局部变量); 解决方案:将线程实例设为窗口类的属性(如self.thread = WorkThread()); 问题原因2:信号未绑定成功(线程启动后才绑定信号); 解决方案:先绑定信号,再启动线程(connect()要在start()之前)。 4. 跨窗口信号绑定后无响应 问题原因:子窗口实例被销毁(比如子窗口是局部变量,函数执行完后被回收); 解决方案:将子窗口实例设为主窗口的属性(如self.child_win = ChildWindow())。 总结 自定义信号核心流程:定义类属性信号(pyqtSignal())→ 绑定槽函数(connect())→ 发射信号(emit()); 核心使用场景:跨窗口通信、多线程进度传递、自定义控件状态通知; 关键注意事项:信号必须是类属性、参数类型要匹配、子线程不能直接操作界面; 下一章预告:我们将学习PyQt5事件处理——重写事件函数(如鼠标事件、键盘事件),实现更灵活的界面交互逻辑。 如果在自定义信号的使用中遇到跨窗口通信、多线程同步的问题,或者想了解更复杂的信号应用场景,欢迎在评论区留言讨论~ -
pyqt5学了有用吗?能赚钱吗 前言 前2天没更新文章,主要是我一直在忙一个客户的开发工作。主要是WP站点的战群管理程序,可以方便的把生成的文章快速发布到各个站群里面,还集成了后台功能,主要都是数据库读写 做了啥? 给各位看个图片 mk7m6f7c.png图片 这是开发过程中迭代的备份文件,终于在昨天晚上完工了 mk7m71pj.png图片 给大家看看是怎么样的一个工程 mk7m873o.png图片 程序是python310写的,总共2900行代码,界面没怎么美化,只是做了排版 mk7m9j1w.png图片 里边做了挺多功能,还插入了md语法解析工具,因为客户是用ai生成的文档批量导入 它的界面也是pyqt5写的,总体程序就不给大家看完了,毕竟客户花钱开发的 结语 接下来我会恢复更新文章,还是希望大家能好好学pyqt5+python,因为是真能赚钱 -
晓翼易支付 PHP API 对接教程:支付退款代付全攻略 晓翼易支付全API对接实操教程:复制即用不踩坑,支付/退款/代付一套搞定 做PHP开发这么久,真心觉得找个好用的支付工具太难了——要么API文档写得云里雾里,示例代码跑起来一堆报错;要么只支持基础支付,退款、代付还得额外对接别的平台;更坑的是有些工具签名逻辑复杂,抠半天代码还验签失败。 mk2lq7ea.png图片 我们字节曜博客一直用晓翼易支付,从对接到底层稳定度都亲测过,现在把全套API的实操教程整理出来,涵盖统一下单、订单查询、退款、代付所有核心功能,每个接口都附可直接复制的PHP代码,替换密钥就能跑,新手半小时也能搞定,分享给有需要的朋友~ 笔者是在竞赛室偷偷写的教程,撰写不易,如有在所难免,文档在此:文档,以文档为准 一、对接前必做:3分钟搞定基础配置(所有API通用) 不管对接哪个接口,先把基础环境和密钥配置好,这步走对了,后面能少踩80%的坑: 1. 先拿核心信息(登录商户后台就能找到) 商户号(mch_id):你的专属标识,在「个人资料」→「基本信息」里一眼就能看到; RSA密钥对:去「API信息」→「密钥管理」生成,会拿到两个密钥——「商户私钥」和「平台公钥」,记好别搞混; 接口网关地址:固定是 https://pay.ziyeyao.com/api/v2,不用自己瞎找; 回调地址(notify_url):自己服务器上的接口地址,得能公网访问,还不能带参数,支付、退款、代付成功后平台会主动通知你。 2. 通用工具函数(必存!所有API都要靠它) 先把签名生成、验签、发送请求的通用函数写好,后续每个接口直接调用就行,不用重复写代码。 <?php /** * 晓翼易支付通用工具类 * 签名、请求、验签全靠它,直接复制用 */ class XyPayUtil { // 替换成你自己的商户私钥(PEM格式,带头尾) private static $privateKey = '-----BEGIN PRIVATE KEY----- 你的商户私钥内容,原样粘贴,别删任何字符 -----END PRIVATE KEY-----'; // 替换成你自己的平台公钥(PEM格式,带头尾) private static $publicKey = '-----BEGIN PUBLIC KEY----- 你的平台公钥内容,原样粘贴 -----END PUBLIC KEY-----'; /** * 生成SHA256WithRSA签名(对接核心,别改逻辑) * @param array $params 要签名的参数数组 * @return string 签名字符串 */ public static function createSign($params) { // 1. 参数按ASCII升序排序(必须这步,不然验签失败) ksort($params); // 2. 拼接成"key=value&key=value"格式 $signStr = http_build_query($params); // 3. 加载私钥 $privateKey = openssl_get_privatekey(self::$privateKey); if (!$privateKey) { throw new Exception("私钥加载失败!检查下私钥格式,是不是漏了头尾标识"); } // 4. 生成签名 openssl_sign($signStr, $sign, $privateKey, OPENSSL_ALGO_SHA256); openssl_free_key($privateKey); // 5. base64编码返回 return base64_encode($sign); } /** * 验证平台返回的签名(防止数据被篡改) * @param array $params 平台返回的参数(不含sign) * @param string $sign 平台返回的签名字符串 * @return bool 验证结果(true=通过) */ public static function verifySign($params, $sign) { ksort($params); $signStr = http_build_query($params); $publicKey = openssl_get_publickey(self::$publicKey); if (!$publicKey) { throw new Exception("公钥加载失败!检查公钥格式是否正确"); } $result = openssl_verify($signStr, base64_decode($sign), $publicKey, OPENSSL_ALGO_SHA256); openssl_free_key($publicKey); return $result === 1; } /** * 发送POST请求(不用自己调curl,直接用) * @param string $url 接口地址 * @param array $params 请求参数 * @return array 解析后的JSON数据 */ public static function postRequest($url, $params) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); // 测试环境可以关闭SSL校验,生产环境一定要打开! curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $response = curl_exec($ch); if (curl_errno($ch)) { throw new Exception("请求失败:" . curl_error($ch)); } curl_close($ch); // 解析返回的JSON $result = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new Exception("返回数据格式错了:" . $response); } return $result; } } ?>重点提醒: 私钥和公钥一定要原样粘贴,别删头尾的-----BEGIN PRIVATE KEY-----这些标识,也别多空格; 生产环境记得把CURLOPT_SSL_VERIFYPEER和CURLOPT_SSL_VERIFYHOST改成true,更安全; 密钥别直接写在代码里,生产环境建议存在单独的配置文件,权限设为只读,防止泄露。 二、核心API对接教程(PHP代码直接复制用) 1. 统一下单API——最常用!创建支付订单 不管是卖会员、卖产品,还是博客变现,第一步都是创建支付订单,这个接口会返回支付链接或二维码,用户付完钱会自动回调你的notify_url。 PHP调用代码(替换3处信息就能跑) <?php require_once 'XyPayUtil.php'; // 引入上面的工具类 // 1. 构造请求参数(按自己的业务改) $params = [ 'mch_id' => '123456', // 替换成你的商户号 'out_trade_no' => 'XY' . date('YmdHis') . rand(1000, 9999), // 订单号规则:前缀+时间戳+随机数,避免重复 'total_fee' => 0.01, // 金额(元),测试用0.01元,正式环境改实际金额 'body' => '字节曜博客VIP会员', // 商品描述,会显示在支付页面 'notify_url' => 'https://你的域名/pay/notify.php', // 支付成功后回调的地址 'timestamp' => time(), // 当前时间戳(秒),不用改 'pay_type' => 'wxpay' // 支付方式:wxpay=微信,alipay=支付宝,按需选 ]; // 2. 生成签名(不用动) $params['sign'] = XyPayUtil::createSign($params); // 3. 调用统一下单接口(固定地址,不用改) $url = 'https://pay.ziyeyao.com/api/v2/unified_order'; try { $result = XyPayUtil::postRequest($url, $params); // 4. 验证返回签名(防止数据被篡改) $sign = $result['sign']; unset($result['sign']); // 先移除sign字段再验签 if (!XyPayUtil::verifySign($result, $sign)) { die("签名验证失败!返回数据可能被篡改"); } // 5. 处理结果 if ($result['code'] == 0) { // 成功:拿到支付链接,可生成二维码让用户扫码 echo "订单创建成功!支付链接:" . $result['pay_url']; // 实际项目中可以跳转支付页面,或生成二维码 } else { die("订单创建失败:" . $result['msg']); } } catch (Exception $e) { die("调用失败:" . $e->getMessage()); } ?>成功返回示例(参考格式) { "code": 0, "msg": "success", "out_trade_no": "XY202601061530221234", "trade_no": "XYPay123456789", "pay_url": "https://pay.ziyeyao.com/pay?order=123456", "timestamp": 1735689622, "sign": "加密后的签名字符串" }2. 订单查询API——查支付状态(防止漏单) 有时候用户付了钱,但回调没收到,这时候就用这个接口主动查订单状态,避免漏单。 PHP调用代码 <?php require_once 'XyPayUtil.php'; // 1. 构造参数(只需要商户号和要查的订单号) $params = [ 'mch_id' => '123456', // 你的商户号 'out_trade_no' => 'XY202601061530221234', // 要查询的订单号(之前创建的) 'timestamp' => time() ]; // 2. 生成签名 $params['sign'] = XyPayUtil::createSign($params); // 3. 调用查询接口 $url = 'https://pay.ziyeyao.com/api/v2/order_query'; try { $result = XyPayUtil::postRequest($url, $params); // 验签 $sign = $result['sign']; unset($result['sign']); if (!XyPayUtil::verifySign($result, $sign)) { die("签名验证失败"); } // 处理结果 if ($result['code'] == 0) { echo "订单状态:" . $result['trade_state']; // SUCCESS=支付成功,NOTPAY=未支付 echo "支付金额:" . $result['total_fee'] . "元"; echo "支付时间:" . $result['pay_time']; } else { die("查询失败:" . $result['msg']); } } catch (Exception $e) { die("查询失败:" . $e->getMessage()); } ?>3. 退款申请API——订单退款(支持全额/部分退) 用户要退款、订单出错,都用这个接口,支持全额或部分退款,退款结果会回调你填的notify_url。 PHP调用代码 <?php require_once 'XyPayUtil.php'; // 1. 构造退款参数 $params = [ 'mch_id' => '123456', // 你的商户号 'out_trade_no' => 'XY202601061530221234', // 原支付订单号 'out_refund_no' => 'RF' . date('YmdHis') . rand(1000, 9999), // 退款单号,自己生成,唯一 'refund_fee' => 0.01, // 退款金额(不能超过原订单金额) 'refund_desc' => '用户主动申请退款', // 退款原因,会显示在后台 'notify_url' => 'https://你的域名/refund/notify.php', // 退款结果回调地址 'timestamp' => time() ]; // 2. 生成签名 $params['sign'] = XyPayUtil::createSign($params); // 3. 调用退款接口 $url = 'https://pay.ziyeyao.com/api/v2/refund'; try { $result = XyPayUtil::postRequest($url, $params); // 验签 $sign = $result['sign']; unset($result['sign']); if (!XyPayUtil::verifySign($result, $sign)) { die("签名验证失败"); } // 处理结果 if ($result['code'] == 0) { echo "退款申请提交成功!退款单号:" . $result['out_refund_no']; // 后续可以通过退款查询接口查进度 } else { die("退款申请失败:" . $result['msg']); } } catch (Exception $e) { die("退款调用失败:" . $e->getMessage()); } ?>4. 退款查询API——查退款进度(用户催单用) 用户问“退款什么时候到账”,用这个接口查状态,不用登录后台手动查。 PHP调用代码 <?php require_once 'XyPayUtil.php'; // 1. 构造参数 $params = [ 'mch_id' => '123456', // 你的商户号 'out_refund_no' => 'RF202601061535225678', // 要查的退款单号 'timestamp' => time() ]; // 2. 生成签名 $params['sign'] = XyPayUtil::createSign($params); // 3. 调用查询接口 $url = 'https://pay.ziyeyao.com/api/v2/refund_query'; try { $result = XyPayUtil::postRequest($url, $params); // 验签 $sign = $result['sign']; unset($result['sign']); if (!XyPayUtil::verifySign($result, $sign)) { die("签名验证失败"); } // 处理结果 if ($result['code'] == 0) { echo "退款状态:" . $result['refund_state']; // SUCCESS=到账,PROCESSING=处理中,FAIL=失败 echo "退款到账时间:" . $result['refund_time']; echo "退款金额:" . $result['refund_fee'] . "元"; } else { die("查询失败:" . $result['msg']); } } catch (Exception $e) { die("查询失败:" . $e->getMessage()); } ?>5. 代付申请API——给用户打款(佣金/提现用) 给合作方结佣金、用户提现,直接用这个接口打款到微信或支付宝,不用手动转账。 PHP调用代码 <?php require_once 'XyPayUtil.php'; // 1. 构造代付参数 $params = [ 'mch_id' => '123456', // 你的商户号 'out_transfer_no' => 'TF' . date('YmdHis') . rand(1000, 9999), // 代付单号,唯一 'payee_type' => 'wx', // 收款类型:wx=微信,alipay=支付宝 'payee_account' => '13800138000', // 收款账号(微信/支付宝手机号或账号) 'payee_name' => '张三', // 收款人姓名(必须和账号实名一致,否则打款失败) 'transfer_fee' => 1.00, // 代付金额(元) 'transfer_desc' => '字节曜博客佣金结算', // 备注,收款人能看到 'notify_url' => 'https://你的域名/transfer/notify.php', // 代付结果回调地址 'timestamp' => time() ]; // 2. 生成签名 $params['sign'] = XyPayUtil::createSign($params); // 3. 调用代付接口 $url = 'https://pay.ziyeyao.com/api/v2/transfer'; try { $result = XyPayUtil::postRequest($url, $params); // 验签 $sign = $result['sign']; unset($result['sign']); if (!XyPayUtil::verifySign($result, $sign)) { die("签名验证失败"); } // 处理结果 if ($result['code'] == 0) { echo "代付申请提交成功!代付单号:" . $result['out_transfer_no']; } else { die("代付申请失败:" . $result['msg']); } } catch (Exception $e) { die("代付调用失败:" . $e->getMessage()); } ?>6. 回调通知API——最容易踩坑!必看 支付、退款、代付成功后,平台会主动往你的notify_url发POST请求,这步处理不好会重复回调、漏单,分享我实测能用的通用回调代码: PHP回调处理代码(支付/退款/代付通用) <?php require_once 'XyPayUtil.php'; // 1. 获取平台回调的参数(兼容JSON和表单提交) $postData = file_get_contents('php://input'); $params = json_decode($postData, true); if (!$params) { $params = $_POST; // 表单格式兼容 } // 2. 验证签名(重中之重!防止伪造回调) try { $sign = $params['sign']; unset($params['sign']); // 移除sign再验签 if (!XyPayUtil::verifySign($params, $sign)) { die("sign error"); // 签名错,直接返回error } // 3. 按类型处理业务逻辑 $type = $params['type']; // trade=支付,refund=退款,transfer=代付 if ($type == 'trade') { // 支付成功:更新订单状态、开通会员等 $outTradeNo = $params['out_trade_no']; // 你的订单号 $tradeState = $params['trade_state']; if ($tradeState == 'SUCCESS') { // 这里写你的业务代码,比如: // update_order_status($outTradeNo, 'paid'); // 更新订单状态 // open_vip($outTradeNo); // 开通会员 echo "success"; // 必须返回纯字符串success,平台才停止回调 exit; } } elseif ($type == 'refund') { // 退款成功:更新退款订单状态 echo "success"; exit; } elseif ($type == 'transfer') { // 代付成功:更新代付订单状态 echo "success"; exit; } // 不管成功失败,都返回success避免重复回调 echo "success"; } catch (Exception $e) { die("fail"); } ?>回调踩坑提醒: 返回值必须是纯字符串success,不能带HTML、空格、换行,否则平台会每隔1分钟回调一次; 业务逻辑要加“幂等性处理”(比如判断订单是否已处理,避免重复开通会员); 回调接口别设超时,建议把业务逻辑异步处理(比如扔到消息队列),避免平台等待超时。 三、为啥推荐晓翼易支付?实测大半年的真心话 用过不少支付工具,晓翼易支付是我最省心的,尤其适合个人开发者和小团队: 代码真的能直接用!上面的代码我博客一直在用,替换商户号和密钥就能跑,不用自己抠签名、调curl; 功能全还稳定:支付、退款、代付一套接口全cover,不用对接多个平台;服务器是集群部署的,高峰期也没掉过链子,接口响应快; 无门槛+费率低:个人开发者不用营业执照就能开通,新用户还有专属费率(比行业平均低0.2%),省不少手续费; 客服靠谱:遇到问题加客服微信,基本秒回,甚至能远程帮你看代码,比自己查文档省太多时间。 现在我身边不少做博客、小电商、工具类产品的开发者都在用,核心就是“对接简单、用着放心”。如果是PHP开发者,不管是个人变现还是小团队业务,直接冲就行——半小时搞定对接,剩下的时间专心做核心业务,不比抠支付接口香? 最后总结几个实操重点 所有接口的核心是“签名和验签”,通用工具类别改逻辑,只换自己的密钥; 订单号、退款单号、代付单号必须唯一,按“前缀+时间戳+随机数”生成最稳妥; 回调一定要返回纯字符串success,业务逻辑加幂等性处理; 生产环境记得打开SSL校验,密钥别泄露,最好存在配置文件里。 如果对接中遇到问题,直接加我QQ2273962061,能远程帮你看一眼,比自己瞎琢磨省太多时间~ -
PyQt5信号与槽原理:从基础到进阶(附参数传递代码) 第11篇:PyQt5信号与槽原理:从基础到进阶(完整代码) 哈喽~ 欢迎来到PyQt5系列的第11篇!进入阶段三,我们将聚焦PyQt5的核心交互机制——信号与槽(Signal & Slot)。前两阶段我们已经用信号与槽实现了按钮点击、控件状态变化等基础交互,但很多同学可能只知其然不知其所以然。这一章我们从原理入手,深入讲解信号与槽的本质、多种绑定方式、参数传递、信号断开等进阶用法,帮你彻底掌握PyQt5交互的核心逻辑! mk237ph4.png图片 一、先搞懂:信号与槽的核心概念(事件驱动模型) 在学习进阶用法前,必须先明确信号与槽的本质,这是理解所有复杂交互的基础: 1. 核心定义 信号(Signal):控件的某个动作或状态变化(如按钮点击clicked、输入框内容变化textChanged、窗口关闭close),是“事件的触发者”; 槽(Slot):信号触发后执行的函数/方法(如按钮点击后执行on_btn_click),是“事件的响应者”; 绑定(connect):将信号与槽关联起来,形成“触发动作→执行响应”的逻辑链,是PyQt5事件驱动模型的核心。 2. 事件驱动模型图解 用户操作(如点击按钮)→ 控件发出信号(clicked)→ 信号触发绑定的槽函数 → 执行槽函数逻辑(如修改界面、处理数据) 特点:无需主动轮询事件,信号触发时自动执行槽函数,效率高、逻辑清晰; 优势:解耦——控件只负责发出信号,不关心哪个槽函数响应;槽函数只负责处理逻辑,不关心哪个信号触发,灵活度极高。 二、信号与槽的基础绑定方式(3种常用) 前两阶段我们主要用了控件.信号.connect(槽函数)的基础绑定方式,这一章我们拓展另外两种常用绑定方式,并对比各自的适用场景。 1. 方式1:代码手动绑定(最常用,灵活度最高) 这是我们之前一直使用的方式,直接通过代码将信号与槽函数关联,支持所有场景。 效果大概就是这样只 mk22u820.png图片 mk22u9e1.png图片 mk22uafw.png图片 完整代码:基础手动绑定 import sys from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel class BasicSignalSlot(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("信号与槽基础绑定演示") self.resize(300, 200) layout = QVBoxLayout() self.label = QLabel("未点击按钮", alignment=1) self.btn = QPushButton("点击触发信号") layout.addWidget(self.label) layout.addWidget(self.btn) self.setLayout(layout) # 手动绑定:按钮clicked信号 → on_btn_click槽函数 self.btn.clicked.connect(self.on_btn_click) def on_btn_click(self): """槽函数:响应按钮点击信号""" self.label.setText("按钮被点击!信号触发成功") if __name__ == "__main__": app = QApplication(sys.argv) window = BasicSignalSlot() window.show() sys.exit(app.exec_())2. 方式2:Qt Designer可视化绑定(快速开发) 对于复杂界面,用Qt Designer(PyQt5-tools自带)拖拽控件后,可直接在界面中绑定信号与槽,无需手动写connect代码,适合快速开发。 操作步骤(核心流程): 打开Qt Designer:终端输入designer(Windows)/ 手动查找designer.exe(macOS/Linux需手动查找路径); 新建QWidget项目,拖拽一个QPushButton和QLabel; 点击菜单栏「Edit → Edit Signals/Slots」,进入信号槽编辑模式; 鼠标点击按钮并拖拽到标签上,松开后弹出绑定窗口; 左侧选择按钮的clicked()信号,右侧选择标签的setText(QString)槽函数,点击「OK」完成绑定; 保存为.ui文件,用pyuic5 -o ui_main.py main.ui转换为Python代码,直接运行即可。 优点:可视化操作,无需记忆信号/槽函数名称;缺点:灵活性不足,复杂逻辑仍需手动写代码。 3. 方式3:装饰器绑定(PyQt5.4+支持,简洁优雅) 用@pyqtSlot()装饰器标记槽函数,无需显式调用connect,代码更简洁,适合小型项目。 完整代码:装饰器绑定 import sys from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel from PyQt5.QtCore import pyqtSlot class DecoratorSignalSlot(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("装饰器绑定信号与槽") self.resize(300, 200) layout = QVBoxLayout() self.label = QLabel("未点击按钮", alignment=1) self.btn = QPushButton("点击触发信号") layout.addWidget(self.label) layout.addWidget(self.btn) self.setLayout(layout) # 装饰器绑定:无需显式connect,通过@pyqtSlot关联 self.btn.clicked.connect(self.on_btn_click) @pyqtSlot() # 标记该函数为槽函数 def on_btn_click(self): self.label.setText("装饰器绑定:信号触发成功") if __name__ == "__main__": app = QApplication(sys.argv) window = DecoratorSignalSlot() window.show() sys.exit(app.exec_())三种绑定方式对比 绑定方式优点缺点适用场景代码手动绑定灵活度最高,支持复杂逻辑需手动写connect代码大多数项目(推荐)Qt Designer绑定可视化操作,快速开发复杂逻辑不支持,灵活性差简单界面、快速原型装饰器绑定代码简洁,无需显式connect功能有限,不支持动态绑定/解绑小型项目、简单交互三、进阶用法1:带参数的信号与槽(核心难点) 很多场景下,信号需要传递参数给槽函数(如输入框内容变化时传递文本、滑块拖动时传递数值),这是信号与槽的核心进阶用法,需掌握3种参数传递方式。 1. 方式1:信号自带参数(直接接收) PyQt5很多内置信号自带参数(如textChanged(str)、valueChanged(int)),槽函数直接定义对应参数即可接收。 完整代码:接收信号自带参数 mk233i3x.png图片 import sys from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QVBoxLayout, QLabel class SignalWithParam(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("带参数的信号与槽(自带参数)") self.resize(350, 200) layout = QVBoxLayout() self.edit = QLineEdit() self.edit.setPlaceholderText("输入文本,实时显示") self.label = QLabel("输入的文本:", alignment=1) layout.addWidget(self.edit) layout.addWidget(self.label) self.setLayout(layout) # 输入框textChanged信号(自带str参数)→ on_text_change槽函数 self.edit.textChanged.connect(self.on_text_change) def on_text_change(self, text): """槽函数:接收信号自带的text参数""" self.label.setText(f"输入的文本:{text}") if __name__ == "__main__": app = QApplication(sys.argv) window = SignalWithParam() window.show() sys.exit(app.exec_())2. 方式2:lambda表达式传递自定义参数 当需要给槽函数传递自定义参数(而非信号自带参数)时,用lambda表达式作为中间桥梁,灵活传递多参数。 完整代码:lambda传递自定义参数 import sys from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QHBoxLayout, QLabel class LambdaParamSignalSlot(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("lambda传递自定义参数") self.resize(400, 200) layout = QHBoxLayout() self.label = QLabel("未点击任何按钮", alignment=1) # 创建3个按钮,传递不同参数 btn1 = QPushButton("按钮1") btn2 = QPushButton("按钮2") btn3 = QPushButton("按钮3") # lambda传递自定义参数:信号触发时,将参数传递给槽函数 btn1.clicked.connect(lambda: self.on_btn_click(1, "按钮1被点击")) btn2.clicked.connect(lambda: self.on_btn_click(2, "按钮2被点击")) btn3.clicked.connect(lambda: self.on_btn_click(3, "按钮3被点击")) layout.addWidget(btn1) layout.addWidget(btn2) layout.addWidget(btn3) layout.addWidget(self.label) self.setLayout(layout) def on_btn_click(self, btn_id, msg): """槽函数:接收自定义参数(按钮ID和提示信息)""" self.label.setText(f"ID:{btn_id} | {msg}") if __name__ == "__main__": app = QApplication(sys.argv) window = LambdaParamSignalSlot() window.show() sys.exit(app.exec_())3. 方式3:functools.partial传递参数(多参数更优雅) 当需要传递多个参数且逻辑复杂时,用functools.partial比lambda更优雅,支持默认参数、关键字参数。 完整代码:partial传递参数 import sys from functools import partial from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel class PartialParamSignalSlot(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("partial传递多参数") self.resize(350, 250) layout = QVBoxLayout() self.label = QLabel("未点击按钮", alignment=1) # 创建按钮,用partial传递多参数(按钮ID、名称、颜色) btn1 = QPushButton("红色按钮") btn2 = QPushButton("蓝色按钮") btn3 = QPushButton("绿色按钮") # partial传递参数:第一个参数是槽函数,后续是自定义参数 btn1.clicked.connect(partial(self.on_btn_click, 1, "红色按钮", "#e74c3c")) btn2.clicked.connect(partial(self.on_btn_click, 2, "蓝色按钮", "#3498db")) btn3.clicked.connect(partial(self.on_btn_click, 3, "绿色按钮", "#2ecc71")) layout.addWidget(btn1) layout.addWidget(btn2) layout.addWidget(btn3) layout.addWidget(self.label) self.setLayout(layout) def on_btn_click(self, btn_id, btn_name, color): """槽函数:接收多个自定义参数""" self.label.setText(f"ID:{btn_id} | 选中:{btn_name}") self.label.setStyleSheet(f"color: {color}; font-size: 16px;") if __name__ == "__main__": app = QApplication(sys.argv) window = PartialParamSignalSlot() window.show() sys.exit(app.exec_())四、进阶用法2:信号与槽的断开(disconnect) 有时需要动态解除信号与槽的绑定(如按钮禁用时停止响应点击),用disconnect()方法实现,支持3种断开方式。 完整代码:信号与槽的断开 import sys from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel class DisconnectSignalSlot(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("信号与槽的断开(disconnect)") self.resize(350, 250) layout = QVBoxLayout() self.label = QLabel("状态:未绑定信号", alignment=1) self.bind_btn = QPushButton("绑定信号") self.unbind_btn = QPushButton("断开信号") self.test_btn = QPushButton("测试信号(点击无响应)") # 绑定“绑定/断开”按钮的信号 self.bind_btn.clicked.connect(self.on_bind) self.unbind_btn.clicked.connect(self.on_unbind) layout.addWidget(self.label) layout.addWidget(self.bind_btn) layout.addWidget(self.unbind_btn) layout.addWidget(self.test_btn) self.setLayout(layout) # 记录信号是否绑定 self.is_bound = False def on_bind(self): """绑定信号""" if not self.is_bound: self.test_btn.clicked.connect(self.on_test_click) self.is_bound = True self.label.setText("状态:信号已绑定(点击测试按钮有响应)") self.test_btn.setText("测试信号(点击有响应)") def on_unbind(self): """断开信号""" if self.is_bound: self.test_btn.clicked.disconnect(self.on_test_click) self.is_bound = False self.label.setText("状态:信号已断开(点击测试按钮无响应)") self.test_btn.setText("测试信号(点击无响应)") def on_test_click(self): """测试信号的槽函数""" self.label.setText("测试信号触发成功!") if __name__ == "__main__": app = QApplication(sys.argv) window = DisconnectSignalSlot() window.show() sys.exit(app.exec_())三种断开方式说明 控件.信号.disconnect(槽函数):断开指定信号与指定槽函数的绑定(最常用); 控件.信号.disconnect():断开该信号的所有绑定槽函数; 控件.disconnect():断开该控件的所有信号与槽的绑定(慎用,可能误删必要绑定)。 五、进阶用法3:同一信号绑定多个槽函数 一个信号可以同时绑定多个槽函数,信号触发时,槽函数会按绑定顺序依次执行,适合复杂逻辑拆分。 完整代码:同一信号绑定多槽函数 import sys from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel class MultiSlotSignal(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("同一信号绑定多个槽函数") self.resize(350, 250) layout = QVBoxLayout() self.label1 = QLabel("槽函数1:未执行", alignment=1) self.label2 = QLabel("槽函数2:未执行", alignment=1) self.label3 = QLabel("槽函数3:未执行", alignment=1) self.btn = QPushButton("点击触发所有槽函数") # 同一信号(btn.clicked)绑定3个槽函数 self.btn.clicked.connect(self.slot1) self.btn.clicked.connect(self.slot2) self.btn.clicked.connect(self.slot3) layout.addWidget(self.label1) layout.addWidget(self.label2) layout.addWidget(self.label3) layout.addWidget(self.btn) self.setLayout(layout) def slot1(self): self.label1.setText("槽函数1:执行成功(顺序1)") def slot2(self): self.label2.setText("槽函数2:执行成功(顺序2)") def slot3(self): self.label3.setText("槽函数3:执行成功(顺序3)") if __name__ == "__main__": app = QApplication(sys.argv) window = MultiSlotSignal() window.show() sys.exit(app.exec_())六、信号与槽的核心原理补充 1. 信号与槽的匹配规则 信号无参数 → 槽函数可无参数,或有默认参数; 信号有参数 → 槽函数必须接收所有参数(或用*args接收任意参数); 槽函数参数不能多于信号参数(否则会报错)。 2. 常用内置信号(高频汇总) 控件常用信号信号含义自带参数QPushButtonclicked()按钮点击无QLineEdittextChanged(str)文本内容变化str(当前文本)QSlidervalueChanged(int)滑块值变化int(当前值)QCheckBoxstateChanged(int)选中状态变化int(2=选中,0=未选中)QComboBoxcurrentIndexChanged(int)选中索引变化int(当前索引)QTableWidgetitemClicked(QTableWidgetItem)单元格点击QTableWidgetItem(单元格对象)七、常见问题排查 1. 信号绑定后槽函数不执行 问题原因1:信号/槽函数名称写错(如clicked写成click,on_btn_click写成on_btn_click1); 问题原因2:控件实例名错误(如self.btn写成btn,未绑定到当前对象); 问题原因3:槽函数参数不匹配(信号有参数但槽函数未接收); 问题原因4:控件被禁用(setDisabled(True)),无法发出信号。 2. 传递参数时报错(TypeError) 问题原因1:lambda表达式语法错误(如参数传递格式错误); 问题原因2:partial传递的参数数量与槽函数不匹配; 问题原因3:信号自带参数与自定义参数冲突(如同时接收信号参数和自定义参数,需用lambda整合)。 3. 断开信号时报错(RuntimeError) 问题原因1:信号未绑定该槽函数,强行断开; 问题原因2:同一信号绑定多次同一槽函数,断开时只需要断开一次; 解决方案:断开前先判断是否绑定(如用is_bound标记)。 总结 核心本质:信号与槽是PyQt5事件驱动模型的核心,实现“触发动作→响应逻辑”的解耦关联; 绑定方式:代码手动绑定(推荐)、Qt Designer可视化绑定(快速开发)、装饰器绑定(简洁); 进阶重点:带参数的信号与槽(lambda/partial传递参数)、信号断开、多槽函数绑定; 关键规则:槽函数参数数量不能多于信号参数,参数类型需匹配; 下一章我们将学习自定义信号——当内置信号无法满足需求时,如何自己定义信号并传递任意参数,实现更复杂的交互逻辑。 如果在实操中遇到信号与槽绑定、参数传递的问题,或者想了解某个复杂场景的信号槽用法,欢迎在评论区留言讨论~ -
PyQt5实战项目:简易Excel表格数据管理器(完整可运行代码) 第10篇:阶段二实战项目:简易Excel表格数据管理器(完整代码) 哈喽~ 欢迎来到PyQt5系列的第10篇!这是阶段二的收官实战项目——我们将整合前9章的核心知识点(布局管理器、QTableWidget、容器控件、标准对话框、自定义交互),开发一个“仿照Excel的简易表格数据管理器”。这个项目覆盖Excel最常用的基础功能(新建/打开/保存、行列增删、数据排序、单元格格式设置等),全程代码可直接运行,帮你把零散的知识点串联成完整的项目开发能力! mk0p53ij.png图片 一、项目核心目标与知识点整合 1. 项目目标 开发一个轻量级表格管理器,实现Excel的核心基础功能,满足日常简单的数据编辑/管理需求,界面风格贴近Excel,操作逻辑直观。 mk0ohoys.png图片 2. 整合的核心知识点 知识点应用场景QTableWidget核心表格控件,承载数据展示/编辑QTabWidget多工作表切换(模拟Excel的多sheet)QGroupBox功能按钮分组(编辑区/格式区/数据区)布局管理器(网格/线性)整体界面排版,保证自适应标准对话框(文件/字体/颜色/消息)打开/保存文件、设置单元格格式、操作提示信号与槽按钮交互、表格事件响应3. 核心功能清单 ✅ 多工作表管理(新建/删除sheet、切换sheet); ✅ 文件操作(新建空白表格、打开CSV文件、保存CSV文件); ✅ 行列管理(插入/删除行/列、清空表格); ✅ 数据编辑(单元格编辑、选中行/列高亮); ✅ 格式设置(单元格字体、颜色、居中对齐); ✅ 数据操作(按列排序、获取选中单元格数据); ✅ 操作提示(成功/失败/警告类消息框)。 二、项目整体架构 1. 界面布局设计 整体界面分为3个区域: 顶部功能区:文件操作按钮(新建/打开/保存) + 工作表切换(QTabWidget); 中部功能按钮区:用QGroupBox分为“编辑区”“格式区”“数据区”,摆放功能按钮; 底部表格区:QTableWidget核心表格,占界面主要空间,支持自适应缩放。 2. 核心功能模块 文件模块:处理CSV文件的新建/打开/保存; 工作表模块:管理多sheet的增删改查; mk0ois8v.png图片 编辑模块:行列增删、清空表格; mk0ojcv5.png图片 格式模块:单元格字体、颜色、对齐方式设置; mk0ojroj.png图片 数据模块:数据排序、选中数据获取。 mk0p33q1.png图片 三、完整代码实现 import sys import os import csv from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QPushButton, QTableWidget, QTableWidgetItem, QTabWidget, QFileDialog, QFontDialog, QColorDialog, QMessageBox ) from PyQt5.QtGui import QFont, QColor, QIcon from PyQt5.QtCore import Qt, pyqtSlot # 主窗口(继承QMainWindow,支持菜单栏/工具栏,更贴近Excel) class SimpleExcel(QMainWindow): def __init__(self): super().__init__() self.init_ui() self.current_file_path = None # 记录当前打开的文件路径 def init_ui(self): # 基础窗口设置 self.setWindowTitle("简易Excel表格管理器") self.resize(1000, 700) self.setMinimumSize(800, 500) # 设置最小尺寸,避免缩放过小 # ---------- 中心控件(所有内容放在中心控件中) ---------- central_widget = QWidget() self.setCentralWidget(central_widget) # ---------- 主布局(垂直) ---------- main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(15) main_layout.setContentsMargins(15, 15, 15, 15) # ---------- 1. 顶部功能区:文件操作 + 工作表切换 ---------- top_layout = QVBoxLayout() # 1.1 文件操作按钮(水平布局) file_btn_layout = QHBoxLayout() self.new_file_btn = QPushButton("新建") self.open_file_btn = QPushButton("打开") self.save_file_btn = QPushButton("保存") # 按钮样式 for btn in [self.new_file_btn, self.open_file_btn, self.save_file_btn]: btn.setFixedSize(80, 35) btn.setStyleSheet("QPushButton { font-size: 14px; }") file_btn_layout.addWidget(self.new_file_btn) file_btn_layout.addWidget(self.open_file_btn) file_btn_layout.addWidget(self.save_file_btn) file_btn_layout.addStretch() # 右侧留白 # 1.2 工作表切换(QTabWidget) self.sheet_tab = QTabWidget() self.sheet_tab.setTabPosition(QTabWidget.South) # 标签在下方(贴近Excel) self.sheet_tab.setTabsClosable(True) # 显示关闭按钮 # 添加默认工作表 self.add_new_sheet("Sheet1") # 顶部布局组合 top_layout.addLayout(file_btn_layout) top_layout.addWidget(self.sheet_tab) # ---------- 2. 中部功能按钮区(分组) ---------- btn_group_layout = QGridLayout() btn_group_layout.setSpacing(10) # 2.1 编辑区分组 edit_group = QGroupBox("编辑区") edit_layout = QVBoxLayout(edit_group) # 编辑区按钮 self.add_row_btn = QPushButton("插入行") self.del_row_btn = QPushButton("删除行") self.add_col_btn = QPushButton("插入列") self.del_col_btn = QPushButton("删除列") self.clear_btn = QPushButton("清空表格") edit_btns = [self.add_row_btn, self.del_row_btn, self.add_col_btn, self.del_col_btn, self.clear_btn] for btn in edit_btns: btn.setFixedSize(80, 30) edit_layout.addWidget(btn) btn_group_layout.addWidget(edit_group, 0, 0) # 2.2 格式区分组 format_group = QGroupBox("格式区") format_layout = QVBoxLayout(format_group) # 格式区按钮 self.font_btn = QPushButton("字体设置") self.color_btn = QPushButton("字体颜色") self.align_btn = QPushButton("居中对齐") format_btns = [self.font_btn, self.color_btn, self.align_btn] for btn in format_btns: btn.setFixedSize(80, 30) format_layout.addWidget(btn) btn_group_layout.addWidget(format_group, 0, 1) # 2.3 数据区分组 data_group = QGroupBox("数据区") data_layout = QVBoxLayout(data_group) # 数据区按钮 self.sort_asc_btn = QPushButton("升序排序") self.sort_desc_btn = QPushButton("降序排序") self.get_data_btn = QPushButton("选中数据") self.add_sheet_btn = QPushButton("新建Sheet") data_btns = [self.sort_asc_btn, self.sort_desc_btn, self.get_data_btn, self.add_sheet_btn] for btn in data_btns: btn.setFixedSize(80, 30) data_layout.addWidget(btn) btn_group_layout.addWidget(data_group, 0, 2) # ---------- 3. 底部表格区 ---------- # 表格区占主要空间,设置拉伸权重 table_layout = QVBoxLayout() table_layout.setStretchFactor(table_layout, 1) # ---------- 组合所有布局 ---------- main_layout.addLayout(top_layout) main_layout.addLayout(btn_group_layout) main_layout.addLayout(table_layout, stretch=1) # 表格区拉伸权重1,占更多空间 # ---------- 信号绑定 ---------- self.bind_signals() # ---------- 核心方法:添加新工作表 ---------- def add_new_sheet(self, sheet_name): """添加新的工作表(QTableWidget)""" # 创建表格控件 table = QTableWidget() table.setRowCount(10) # 默认10行 table.setColumnCount(5) # 默认5列 # 设置表格样式(贴近Excel) table.setAlternatingRowColors(True) # 隔行变色 table.horizontalHeader().setStretchLastSection(True) # 最后一列拉伸 table.setSelectionBehavior(QTableWidget.SelectRows) # 选中整行 # 添加到标签页 self.sheet_tab.addTab(table, sheet_name) # 切换到新工作表 self.sheet_tab.setCurrentWidget(table) return table # ---------- 信号绑定 ---------- def bind_signals(self): # 文件操作 self.new_file_btn.clicked.connect(self.on_new_file) self.open_file_btn.clicked.connect(self.on_open_file) self.save_file_btn.clicked.connect(self.on_save_file) # 工作表操作 self.add_sheet_btn.clicked.connect(self.on_add_sheet) self.sheet_tab.tabCloseRequested.connect(self.on_close_sheet) # 编辑操作 self.add_row_btn.clicked.connect(self.on_add_row) self.del_row_btn.clicked.connect(self.on_del_row) self.add_col_btn.clicked.connect(self.on_add_col) self.del_col_btn.clicked.connect(self.on_del_col) self.clear_btn.clicked.connect(self.on_clear_table) # 格式操作 self.font_btn.clicked.connect(self.on_set_font) self.color_btn.clicked.connect(self.on_set_color) self.align_btn.clicked.connect(self.on_set_align) # 数据操作 self.sort_asc_btn.clicked.connect(lambda: self.on_sort_data(Qt.AscendingOrder)) self.sort_desc_btn.clicked.connect(lambda: self.on_sort_data(Qt.DescendingOrder)) self.get_data_btn.clicked.connect(self.on_get_selected_data) # ---------- 文件操作槽函数 ---------- def on_new_file(self): """新建空白表格""" # 清空所有工作表 while self.sheet_tab.count() > 0: self.sheet_tab.removeTab(0) # 添加默认工作表 self.add_new_sheet("Sheet1") self.current_file_path = None QMessageBox.information(self, "成功", "新建空白表格完成!") def on_open_file(self): """打开CSV文件""" file_path, _ = QFileDialog.getOpenFileName( self, "打开CSV文件", "", "CSV Files (*.csv);;All Files (*.*)" ) if not file_path: return try: # 清空现有工作表 while self.sheet_tab.count() > 0: self.sheet_tab.removeTab(0) # 创建新工作表并加载数据 table = self.add_new_sheet(os.path.basename(file_path)) # 读取CSV文件 with open(file_path, "r", encoding="utf-8") as f: reader = csv.reader(f) # 获取所有行数据 rows = list(reader) if not rows: QMessageBox.warning(self, "提示", "文件为空!") return # 设置表格行列数 table.setRowCount(len(rows)) table.setColumnCount(len(rows[0])) # 填充数据 for row_idx, row_data in enumerate(rows): for col_idx, col_data in enumerate(row_data): item = QTableWidgetItem(str(col_data)) item.setTextAlignment(Qt.AlignCenter) table.setItem(row_idx, col_idx, item) self.current_file_path = file_path QMessageBox.information(self, "成功", f"已打开文件:{os.path.basename(file_path)}") except Exception as e: QMessageBox.critical(self, "错误", f"打开文件失败:{str(e)}") def on_save_file(self): """保存CSV文件""" # 获取当前表格 current_table = self.sheet_tab.currentWidget() if not isinstance(current_table, QTableWidget): QMessageBox.warning(self, "提示", "无有效表格可保存!") return # 获取保存路径 if self.current_file_path: save_path = self.current_file_path else: save_path, _ = QFileDialog.getSaveFileName( self, "保存CSV文件", "", "CSV Files (*.csv)" ) if not save_path: return # 补充后缀 if not save_path.endswith(".csv"): save_path += ".csv" try: # 写入CSV文件 with open(save_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) # 遍历表格所有行 for row in range(current_table.rowCount()): row_data = [] for col in range(current_table.columnCount()): item = current_table.item(row, col) row_data.append(item.text() if item else "") writer.writerow(row_data) self.current_file_path = save_path QMessageBox.information(self, "成功", f"文件已保存到:{save_path}") except Exception as e: QMessageBox.critical(self, "错误", f"保存文件失败:{str(e)}") # ---------- 工作表操作槽函数 ---------- def on_add_sheet(self): """新建工作表""" sheet_count = self.sheet_tab.count() + 1 self.add_new_sheet(f"Sheet{sheet_count}") QMessageBox.information(self, "成功", f"新建工作表Sheet{sheet_count}完成!") def on_close_sheet(self, index): """关闭指定工作表""" if self.sheet_tab.count() <= 1: QMessageBox.warning(self, "提示", "至少保留一个工作表!") return self.sheet_tab.removeTab(index) QMessageBox.information(self, "成功", "工作表已删除!") # ---------- 编辑操作槽函数 ---------- def get_current_table(self): """获取当前选中的表格,辅助函数""" table = self.sheet_tab.currentWidget() if not isinstance(table, QTableWidget): QMessageBox.warning(self, "提示", "无有效表格!") return None return table def on_add_row(self): """插入行""" table = self.get_current_table() if not table: return # 在选中行下方插入,无选中则在最后插入 current_row = table.currentRow() insert_row = current_row + 1 if current_row >= 0 else table.rowCount() table.insertRow(insert_row) QMessageBox.information(self, "成功", f"在第{insert_row+1}行插入新行!") def on_del_row(self): """删除行""" table = self.get_current_table() if not table: return current_row = table.currentRow() if current_row < 0: QMessageBox.warning(self, "提示", "请先选中要删除的行!") return table.removeRow(current_row) QMessageBox.information(self, "成功", f"第{current_row+1}行已删除!") def on_add_col(self): """插入列""" table = self.get_current_table() if not table: return current_col = table.currentColumn() insert_col = current_col + 1 if current_col >= 0 else table.columnCount() table.insertColumn(insert_col) QMessageBox.information(self, "成功", f"在第{insert_col+1}列插入新列!") def on_del_col(self): """删除列""" table = self.get_current_table() if not table: return current_col = table.currentColumn() if current_col < 0: QMessageBox.warning(self, "提示", "请先选中要删除的列!") return table.removeColumn(current_col) QMessageBox.information(self, "成功", f"第{current_col+1}列已删除!") def on_clear_table(self): """清空表格(保留行列数)""" table = self.get_current_table() if not table: return # 确认清空 reply = QMessageBox.question(self, "确认", "确定要清空表格所有数据吗?", QMessageBox.Yes | QMessageBox.No) if reply != QMessageBox.Yes: return # 清空所有单元格内容 for row in range(table.rowCount()): for col in range(table.columnCount()): table.setItem(row, col, QTableWidgetItem("")) QMessageBox.information(self, "成功", "表格数据已清空!") # ---------- 格式操作槽函数 ---------- def on_set_font(self): """设置选中单元格字体""" table = self.get_current_table() if not table: return # 获取选中单元格 selected_items = table.selectedItems() if not selected_items: QMessageBox.warning(self, "提示", "请先选中要设置格式的单元格!") return # 弹出字体选择对话框 font, ok = QFontDialog.getFont(QFont("微软雅黑", 12), self, "选择字体") if ok: for item in selected_items: item.setFont(font) QMessageBox.information(self, "成功", "字体设置完成!") def on_set_color(self): """设置选中单元格字体颜色""" table = self.get_current_table() if not table: return selected_items = table.selectedItems() if not selected_items: QMessageBox.warning(self, "提示", "请先选中要设置格式的单元格!") return # 弹出颜色选择对话框 color = QColorDialog.getColor(Qt.black, self, "选择字体颜色") if color.isValid(): for item in selected_items: item.setForeground(color) QMessageBox.information(self, "成功", "字体颜色设置完成!") def on_set_align(self): """设置选中单元格居中对齐""" table = self.get_current_table() if not table: return selected_items = table.selectedItems() if not selected_items: QMessageBox.warning(self, "提示", "请先选中要设置格式的单元格!") return for item in selected_items: item.setTextAlignment(Qt.AlignCenter) QMessageBox.information(self, "成功", "居中对齐设置完成!") # ---------- 数据操作槽函数 ---------- def on_sort_data(self, order): """数据排序(按选中列)""" table = self.get_current_table() if not table: return current_col = table.currentColumn() if current_col < 0: QMessageBox.warning(self, "提示", "请先选中要排序的列!") return # 排序 table.sortItems(current_col, order) sort_type = "升序" if order == Qt.AscendingOrder else "降序" QMessageBox.information(self, "成功", f"按第{current_col+1}列{sort_type}排序完成!") def on_get_selected_data(self): """获取选中单元格数据""" table = self.get_current_table() if not table: return selected_items = table.selectedItems() if not selected_items: QMessageBox.warning(self, "提示", "请先选中单元格!") return # 整理选中数据 data_text = "选中数据:\n" for item in selected_items: data_text += f"行{item.row()+1}列{item.column()+1}:{item.text()}\n" QMessageBox.information(self, "选中数据", data_text) # ---------- 程序入口 ---------- if __name__ == "__main__": # 解决中文显示问题(可选) QApplication.setStyle("Fusion") app = QApplication(sys.argv) window = SimpleExcel() window.show() sys.exit(app.exec_())四、核心功能解析 1. 多工作表管理 核心方法:add_new_sheet() 创建新的QTableWidget并添加到QTabWidget; 关闭保护:on_close_sheet() 中判断工作表数量,确保至少保留1个; 标签位置:setTabPosition(QTabWidget.South) 把标签放在下方,贴近Excel的sheet位置。 2. CSV文件读写 读取:用csv.reader()读取所有行,再逐行填充到QTableWidget; 写入:遍历表格所有单元格,收集数据后用csv.writer()写入; 编码:统一用encoding="utf-8",避免中文乱码(Windows若乱码可尝试gbk)。 3. 单元格格式设置 选中单元格:selectedItems() 获取所有选中的单元格项; 字体设置:QFontDialog.getFont() 弹出字体选择框,返回选中的字体对象; 颜色设置:QColorDialog.getColor() 返回颜色对象,用setForeground()应用; 对齐方式:setTextAlignment(Qt.AlignCenter) 设置居中,支持左/右对齐。 4. 行列操作 插入行/列:insertRow()/insertColumn(),优先在选中位置插入,无选中则在末尾; 删除行/列:removeRow()/removeColumn(),需先判断是否选中; 清空表格:遍历所有单元格,设置为空字符串(保留行列数)。 五、常见问题排查 1. 文件相关问题 CSV打开乱码:读写时确保encoding="utf-8",Windows系统可替换为encoding="gbk"; 保存文件失败:检查文件路径是否有权限(如系统盘根目录),建议保存到用户目录; 文件为空提示:读取CSV时判断rows是否为空,避免表格无数据。 2. 表格操作问题 无有效表格提示:所有表格操作前调用get_current_table(),判断是否为QTableWidget; 选中行/列无响应:确保表格setSelectionBehavior(QTableWidget.SelectRows),支持整行选中; 排序无效果:排序需指定列(currentColumn()),确保选中了有效列。 3. 界面适配问题 窗口缩放过小:设置setMinimumSize(800, 500),避免控件重叠; 表格不自适应:horizontalHeader().setStretchLastSection(True) 让最后一列拉伸; 按钮排版混乱:用QGridLayout管理分组按钮,固定按钮大小,避免拉伸变形。 六、功能拓展建议(进阶方向) 这个基础版本可拓展更多Excel功能,适合进阶学习: 单元格合并:setSpan() 实现单元格合并/拆分; 公式计算:支持简单公式(如求和=SUM(A1:A5)); 数据筛选:按条件筛选表格数据; 样式保存:支持单元格背景色、边框设置; Excel格式支持:用openpyxl/pandas库支持.xlsx文件(需额外安装)。 总结 项目核心价值:这是阶段二的收官项目,整合了前9章的所有核心知识点,从“零散知识点”到“完整项目”,帮你建立PyQt5项目开发的整体思维; 核心技术点:QTableWidget是表格开发的核心,QTabWidget实现多页面,QGroupBox规整界面,标准对话框处理交互,信号与槽串联所有功能; 实战思维:开发GUI项目需先设计界面布局→拆分功能模块→实现核心功能→处理异常→优化体验; 后续方向:阶段三将进入PyQt5进阶内容(信号与槽深入、自定义控件、多线程、数据库交互),进一步提升项目开发能力。 如果在运行代码时遇到问题,或者想拓展某个进阶功能(如单元格合并、Excel格式支持),欢迎在评论区留言讨论~ -
PyQt5容器控件:QTabWidget与QGroupBox(附多功能窗口实战) 第9篇:PyQt5容器控件:QTabWidget与QGroupBox(完整代码) 哈喽~ 欢迎来到PyQt5系列的第9篇!上一章我们掌握了QDialog和各类标准对话框,解决了界面的交互弹窗问题。今天我们聚焦容器控件——这类控件的核心作用是“管理其他控件”,把功能相关的控件分组/分页面摆放,让复杂界面更规整、层次感更强。我们会详细讲解两种高频容器控件:QGroupBox(分组框)和QTabWidget(标签页控件),全程搭配完整可运行代码,新手也能轻松掌握! mk0o8rmi.png图片 一、先明确:容器控件的核心定位 容器控件本身不实现业务功能,而是作为“控件的容器”存在,核心价值是提升界面的结构化和可读性: QGroupBox(分组框):把功能相关的控件(如设置项、表单字段)“分组”展示,带标题和可选边框,用户能快速识别控件的功能归属; QTabWidget(标签页控件):把不同功能模块(如文本编辑、数据表格、系统设置)“分页面”展示,通过点击标签切换页面,极大节省界面空间,适合多功能集成的窗口。 二、QGroupBox(分组框)详解:控件分组管理 QGroupBox是最基础的容器控件,核心是“给控件加分组标题+边框”,支持“可勾选分组”(勾选后才启用内部控件),是规整表单/设置界面的必备工具。 1. QGroupBox基础用法(完整代码) 实现两个分组框(“界面设置”和“数据设置”),演示分组框的基础属性和可勾选功能: mk0ns3om.png图片 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QCheckBox, QComboBox, QPushButton ) from PyQt5.QtCore import Qt class GroupBoxDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QGroupBox分组框基础演示") self.resize(500, 400) # 主布局(垂直):两个分组框 + 确认按钮 main_layout = QVBoxLayout() main_layout.setSpacing(20) main_layout.setContentsMargins(30, 30, 30, 30) # ---------- 分组框1:界面设置(普通分组,不可勾选) ---------- ui_group = QGroupBox("界面设置") # 设置分组标题 ui_group.setAlignment(Qt.AlignCenter) # 标题居中对齐 # 分组框内部布局(网格布局) ui_layout = QHBoxLayout() ui_layout.setSpacing(15) # 添加内部控件 theme_label = QLabel("主题:") theme_combo = QComboBox() theme_combo.addItems(["浅色主题", "深色主题", "系统主题"]) font_size_label = QLabel("字体大小:") font_size_combo = QComboBox() font_size_combo.addItems(["12px", "14px", "16px"]) # 添加到分组框布局 ui_layout.addWidget(theme_label) ui_layout.addWidget(theme_combo) ui_layout.addWidget(font_size_label) ui_layout.addWidget(font_size_combo) # 绑定布局到分组框 ui_group.setLayout(ui_layout) # ---------- 分组框2:数据设置(可勾选分组,勾选才启用内部控件) ---------- data_group = QGroupBox("数据设置") data_group.setCheckable(True) # 开启勾选功能 data_group.setChecked(False) # 默认未勾选(内部控件禁用) # 分组框内部布局(水平) data_layout = QHBoxLayout() data_layout.setSpacing(15) # 添加内部控件 auto_save_check = QCheckBox("自动保存") auto_sync_check = QCheckBox("自动同步") # 添加到分组框布局 data_layout.addWidget(auto_save_check) data_layout.addWidget(auto_sync_check) # 绑定布局到分组框 data_group.setLayout(data_layout) # 确认按钮 confirm_btn = QPushButton("保存设置") confirm_btn.setFixedSize(100, 30) # 将分组框和按钮添加到主布局 main_layout.addWidget(ui_group) main_layout.addWidget(data_group) main_layout.addWidget(confirm_btn, alignment=Qt.AlignCenter) # 绑定主布局到窗口 self.setLayout(main_layout) if __name__ == "__main__": app = QApplication(sys.argv) window = GroupBoxDemo() window.show() sys.exit(app.exec_())2. QGroupBox核心方法解析 QGroupBox的核心围绕“分组样式”和“交互控制”,重点掌握: 方法作用setTitle(标题文本)设置分组框的标题setAlignment(对齐方式)设置标题对齐(如Qt.AlignCenter居中、Qt.AlignLeft左对齐)setCheckable(True/False)是否开启“勾选功能”(勾选后内部控件启用,未勾选禁用)setChecked(True/False)设置勾选状态(仅当setCheckable(True)时生效)setStyleSheet(样式)自定义分组框样式(如边框颜色、标题字体)3. QGroupBox实战:表单字段分组 结合之前学的表单布局,用QGroupBox将用户表单分为“基础信息”和“扩展信息”两组,提升表单可读性: mk0nukbw.png图片 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QFormLayout, QGroupBox, QLineEdit, QComboBox, QTextEdit, QPushButton ) from PyQt5.QtCore import Qt class GroupBoxFormDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QGroupBox表单分组实战") self.resize(500, 450) # 主布局 main_layout = QVBoxLayout() main_layout.setSpacing(20) main_layout.setContentsMargins(30, 30, 30, 30) # ---------- 分组1:基础信息 ---------- basic_group = QGroupBox("基础信息(必填)") basic_layout = QFormLayout() basic_layout.setSpacing(15) # 添加表单控件 basic_layout.addRow("用户名:", QLineEdit()) basic_layout.addRow("密码:", QLineEdit()) basic_layout.addRow("性别:", QComboBox()) basic_layout.addRow("手机号:", QLineEdit()) # 绑定布局 basic_group.setLayout(basic_layout) # ---------- 分组2:扩展信息 ---------- extend_group = QGroupBox("扩展信息(选填)") extend_layout = QFormLayout() extend_layout.setSpacing(15) # 添加表单控件 extend_layout.addRow("邮箱:", QLineEdit()) extend_layout.addRow("地址:", QLineEdit()) extend_layout.addRow("备注:", QTextEdit()) # 绑定布局 extend_group.setLayout(extend_layout) # 提交按钮 submit_btn = QPushButton("提交表单") submit_btn.setFixedSize(100, 30) # 添加到主布局 main_layout.addWidget(basic_group) main_layout.addWidget(extend_group) main_layout.addWidget(submit_btn, alignment=Qt.AlignCenter) self.setLayout(main_layout) if __name__ == "__main__": app = QApplication(sys.argv) window = GroupBoxFormDemo() window.show() sys.exit(app.exec_())三、QTabWidget(标签页控件)详解:多页面切换 QTabWidget是“多页面”界面的核心控件,比如浏览器的标签页、软件的功能面板,核心是“一个窗口承载多个功能页面”,极大节省界面空间。 1. QTabWidget基础用法(完整代码) 实现包含“文本编辑页”“数据表格页”“设置页”的多标签窗口,演示标签页的添加、切换、删除等核心操作: mk0nwa5q.png图片 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTextEdit, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QComboBox ) from PyQt5.QtGui import QIcon from PyQt5.QtCore import Qt class TabWidgetDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QTabWidget标签页基础演示") self.resize(800, 500) # 主布局(垂直):标签页控件 + 操作按钮 main_layout = QVBoxLayout() main_layout.setSpacing(15) main_layout.setContentsMargins(15, 15, 15, 15) # ---------- 创建QTabWidget核心控件 ---------- self.tab_widget = QTabWidget() # 设置标签位置(可选:North/南South/东East/西West,默认North) self.tab_widget.setTabPosition(QTabWidget.North) # 允许关闭标签页(显示×按钮) self.tab_widget.setTabsClosable(True) # 标签栏可滚动(标签过多时显示左右箭头) self.tab_widget.setTabBarAutoHide(False) # ---------- 添加标签页 ---------- # 页1:文本编辑页 text_page = QWidget() text_layout = QVBoxLayout(text_page) text_edit = QTextEdit() text_edit.setPlaceholderText("文本编辑页:在这里输入内容...") text_layout.addWidget(text_edit) # 添加标签页(参数:页面控件,标签文本,可选:图标) self.tab_widget.addTab(text_page, "文本编辑") # 页2:数据表格页 table_page = QWidget() table_layout = QVBoxLayout(table_page) table = QTableWidget() table.setRowCount(5) table.setColumnCount(4) table.setHorizontalHeaderLabels(["ID", "姓名", "性别", "年龄"]) # 填充测试数据 test_data = [ [1, "张三", "男", 25], [2, "李四", "女", 28], [3, "王五", "男", 30], [4, "赵六", "女", 22], [5, "钱七", "男", 27] ] for row in range(5): for col in range(4): item = QTableWidgetItem(str(test_data[row][col])) item.setTextAlignment(Qt.AlignCenter) table.setItem(row, col, item) table_layout.addWidget(table) self.tab_widget.addTab(table_page, "数据表格") # 页3:设置页 setting_page = QWidget() setting_layout = QVBoxLayout(setting_page) # 设置页用水平布局放控件 setting_h_layout = QHBoxLayout() setting_h_layout.addWidget(QLabel("主题:")) setting_h_layout.addWidget(QComboBox()) setting_layout.addLayout(setting_h_layout) setting_layout.addWidget(QLabel("设置页:在这里配置系统参数...")) self.tab_widget.addTab(setting_page, "系统设置") # ---------- 操作按钮区 ---------- btn_layout = QHBoxLayout() add_tab_btn = QPushButton("添加新标签页") close_tab_btn = QPushButton("关闭当前标签页") switch_tab_btn = QPushButton("切换到设置页") for btn in [add_tab_btn, close_tab_btn, switch_tab_btn]: btn.setFixedSize(120, 30) btn_layout.addWidget(add_tab_btn) btn_layout.addWidget(close_tab_btn) btn_layout.addWidget(switch_tab_btn) btn_layout.addStretch() # ---------- 绑定布局和信号 ---------- main_layout.addWidget(self.tab_widget) main_layout.addLayout(btn_layout) self.setLayout(main_layout) # 信号绑定 add_tab_btn.clicked.connect(self.add_new_tab) close_tab_btn.clicked.connect(self.close_current_tab) switch_tab_btn.clicked.connect(self.switch_to_setting_tab) # 标签页关闭信号(点击×按钮触发) self.tab_widget.tabCloseRequested.connect(self.close_specified_tab) # 标签页切换信号 self.tab_widget.currentChanged.connect(self.on_tab_changed) # ---------- 标签页操作槽函数 ---------- def add_new_tab(self): """添加新的空白标签页""" new_page = QWidget() new_layout = QVBoxLayout(new_page) new_layout.addWidget(QLabel("这是新添加的空白标签页")) # 添加标签页(带自定义文本) tab_index = self.tab_widget.addTab(new_page, f"新标签{self.tab_widget.count()+1}") # 切换到新添加的标签页 self.tab_widget.setCurrentIndex(tab_index) def close_current_tab(self): """关闭当前选中的标签页""" current_index = self.tab_widget.currentIndex() if current_index >= 0: # 确保有标签页可关闭 self.tab_widget.removeTab(current_index) def close_specified_tab(self, index): """关闭指定索引的标签页(点击×按钮触发)""" self.tab_widget.removeTab(index) def switch_to_setting_tab(self): """切换到设置页(通过索引,设置页是第3个,索引为2)""" self.tab_widget.setCurrentIndex(2) def on_tab_changed(self, index): """标签页切换时触发,打印当前标签页名称""" tab_text = self.tab_widget.tabText(index) print(f"当前切换到:{tab_text}页") if __name__ == "__main__": app = QApplication(sys.argv) window = TabWidgetDemo() window.show() sys.exit(app.exec_())2. QTabWidget核心方法解析 QTabWidget的核心围绕“标签页的增删改查和切换”,重点掌握: 方法作用addTab(页面控件, 标签文本, 图标)添加标签页(返回新标签页的索引)insertTab(索引, 页面控件, 标签文本)在指定索引位置插入标签页removeTab(索引)删除指定索引的标签页setCurrentIndex(索引)切换到指定索引的标签页currentIndex()获取当前选中的标签页索引tabText(索引)获取指定索引标签页的文本setTabPosition(位置)设置标签位置(North/South/East/West)setTabsClosable(True)显示标签页的关闭按钮(×)setTabIcon(索引, QIcon)给指定标签页设置图标3. QTabWidget实战:带图标的标签页 给标签页添加图标(提升界面美观度),并实现“标签页内容自适应”: mk0o39iv.png图片 图片素材 mk0o4oaf.png图片 mk0o4v3i.png图片 mk0o50fc.png图片 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QTabWidget, QTextEdit, QTableWidget, QTableWidgetItem ) from PyQt5.QtGui import QIcon from PyQt5.QtCore import Qt # 注意:需提前准备3个图标文件(text.png、table.png、setting.png)放在同级目录 # 若无图标,可注释掉setTabIcon相关代码 # 本站提供素材图片下载 class TabWidgetIconDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QTabWidget带图标标签页实战") self.resize(800, 500) # 主布局 main_layout = QVBoxLayout() self.tab_widget = QTabWidget() # 页1:文本编辑页(带图标) text_page = QWidget() text_layout = QVBoxLayout(text_page) text_layout.addWidget(QTextEdit("文本编辑页")) self.tab_widget.addTab(text_page, "文本编辑") # 设置图标 self.tab_widget.setTabIcon(0, QIcon("text.png")) # 页2:数据表格页(带图标) table_page = QWidget() table_layout = QVBoxLayout(table_page) table = QTableWidget(5, 4) table.setHorizontalHeaderLabels(["ID", "姓名", "性别", "年龄"]) table_layout.addWidget(table) self.tab_widget.addTab(table_page, "数据表格") self.tab_widget.setTabIcon(1, QIcon("table.png")) # 页3:设置页(带图标) setting_page = QWidget() setting_layout = QVBoxLayout(setting_page) setting_layout.addWidget(QTextEdit("系统设置页")) self.tab_widget.addTab(setting_page, "系统设置") self.tab_widget.setTabIcon(2, QIcon("setting.png")) # 标签页内容自适应 self.tab_widget.setSizePolicy( self.tab_widget.sizePolicy().horizontalPolicy(), self.tab_widget.sizePolicy().verticalPolicy() ) main_layout.addWidget(self.tab_widget) self.setLayout(main_layout) if __name__ == "__main__": app = QApplication(sys.argv) window = TabWidgetIconDemo() window.show() sys.exit(app.exec_())四、综合案例:多功能工具窗口(QTabWidget+QGroupBox) 整合QTabWidget(多页面)和QGroupBox(分组),实现一个“多功能工具窗口”——包含文本编辑页、数据表格页、设置页(设置页用QGroupBox分组),贴合实际项目的界面风格: mk0o7d0r.png图片 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTextEdit, QTableWidget, QTableWidgetItem, QGroupBox, QLabel, QComboBox, QCheckBox, QPushButton, QFileDialog, QMessageBox ) from PyQt5.QtCore import Qt class MultiToolWindow(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("多功能工具窗口(QTabWidget+QGroupBox)") self.resize(800, 600) # 主布局 main_layout = QVBoxLayout() self.tab_widget = QTabWidget() # ---------- 页1:文本编辑页 ---------- text_page = QWidget() text_layout = QVBoxLayout(text_page) # 文本编辑区 self.text_edit = QTextEdit() self.text_edit.setPlaceholderText("请输入文本...") # 操作按钮 text_btn_layout = QHBoxLayout() open_btn = QPushButton("打开文件") save_btn = QPushButton("保存文件") for btn in [open_btn, save_btn]: btn.setFixedSize(80, 30) text_btn_layout.addWidget(open_btn) text_btn_layout.addWidget(save_btn) text_btn_layout.addStretch() # 添加到文本页布局 text_layout.addLayout(text_btn_layout) text_layout.addWidget(self.text_edit) self.tab_widget.addTab(text_page, "文本编辑") # ---------- 页2:数据表格页 ---------- table_page = QWidget() table_layout = QVBoxLayout(table_page) self.table = QTableWidget() self.table.setRowCount(0) self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(["ID", "姓名", "性别", "年龄"]) table_layout.addWidget(self.table) self.tab_widget.addTab(table_page, "数据表格") # ---------- 页3:设置页(用QGroupBox分组) ---------- setting_page = QWidget() setting_layout = QVBoxLayout(setting_page) # 分组1:界面设置 ui_group = QGroupBox("界面设置") ui_layout = QHBoxLayout() ui_layout.addWidget(QLabel("主题:")) ui_layout.addWidget(QComboBox()) ui_group.setLayout(ui_layout) # 分组2:数据设置 data_group = QGroupBox("数据设置") data_group.setCheckable(True) data_layout = QHBoxLayout() data_layout.addWidget(QCheckBox("自动保存")) data_layout.addWidget(QCheckBox("自动同步")) data_group.setLayout(data_layout) # 保存设置按钮 save_setting_btn = QPushButton("保存设置") save_setting_btn.setFixedSize(100, 30) # 添加到设置页布局 setting_layout.addWidget(ui_group) setting_layout.addWidget(data_group) setting_layout.addWidget(save_setting_btn, alignment=Qt.AlignCenter) self.tab_widget.addTab(setting_page, "系统设置") # ---------- 绑定布局和信号 ---------- main_layout.addWidget(self.tab_widget) self.setLayout(main_layout) # 信号绑定 open_btn.clicked.connect(self.open_file) save_btn.clicked.connect(self.save_file) save_setting_btn.clicked.connect(self.save_setting) # ---------- 文本页功能 ---------- def open_file(self): file_path, _ = QFileDialog.getOpenFileName(self, "打开文件", "", "Text Files (*.txt)") if file_path: with open(file_path, "r", encoding="utf-8") as f: self.text_edit.setText(f.read()) QMessageBox.information(self, "成功", "文件打开成功!") def save_file(self): file_path, _ = QFileDialog.getSaveFileName(self, "保存文件", "", "Text Files (*.txt)") if file_path: with open(file_path, "w", encoding="utf-8") as f: f.write(self.text_edit.toPlainText()) QMessageBox.information(self, "成功", "文件保存成功!") # ---------- 设置页功能 ---------- def save_setting(self): QMessageBox.information(self, "成功", "设置保存成功!") if __name__ == "__main__": app = QApplication(sys.argv) window = MultiToolWindow() window.show() sys.exit(app.exec_())五、常见问题排查 1. QGroupBox相关问题 问题1:分组框内控件排列混乱 → 解决:分组框必须绑定布局(如QVBoxLayout/QHBoxLayout),再将控件添加到布局,而非直接添加到分组框; 问题2:可勾选分组框勾选后内部控件仍禁用 → 解决:确保内部控件未手动设置setEnabled(False),分组框的setCheckable(True)仅控制“是否可勾选”,setChecked(True)才会启用内部控件; 问题3:分组框标题不显示 → 解决:检查setTitle()是否传入空字符串,或样式表覆盖了标题显示(可重置样式表测试)。 2. QTabWidget相关问题 问题1:标签页内容不自适应窗口缩放 → 解决:标签页的页面控件(如QWidget)必须绑定布局,且布局内的控件未设置固定大小; 问题2:标签页关闭按钮不显示 → 解决:调用setTabsClosable(True),且确保Qt版本支持(PyQt5 5.6+均支持); 问题3:添加图标后标签页不显示图标 → 解决:检查图标路径是否正确(绝对路径/相对路径),图标格式是否支持(png/jpg/ico); 问题4:标签页切换无响应 → 解决:currentChanged信号绑定的槽函数需检查参数(索引)是否正确,避免索引越界。 总结 QGroupBox:核心用于控件分组,提升界面可读性,支持“可勾选分组”(勾选启用内部控件),是表单/设置界面的必备工具; QTabWidget:核心用于多页面切换,节省界面空间,支持标签页增删改查、图标设置、关闭按钮等,是多功能窗口的核心控件; 组合使用:实际项目中常将QTabWidget(多页面)和QGroupBox(分组)结合,比如“设置页用QGroupBox分组,多个功能页用QTabWidget切换”; 下一章我们将进入阶段二的收尾——阶段二实战项目:仿照Excel简易表格数据管理器,整合布局管理器、表格控件、容器控件的核心知识点,完成一个完整的实战项目。 如果在容器控件开发中遇到界面排版、标签页切换的问题,或者想拓展更复杂的多标签功能(如拖拽标签页、标签页右键菜单),欢迎在评论区留言讨论~ -
PyQt5 对话框控件:QDialog 与标准对话框(附登录实战代码) 第8篇:PyQt5对话框控件:QDialog与标准对话框(完整代码) 哈喽~ 欢迎来到PyQt5系列的第8篇!上一章我们掌握了QTextEdit(富文本)和QTableWidget(表格)的核心用法,今天聚焦对话框控件——对话框是PyQt5界面交互的“重要桥梁”,比如提示信息、选择文件、用户登录验证等场景都离不开它。我们会详细讲解两类对话框:标准对话框(系统预制,如消息框、文件选择框)和自定义对话框(基于QDialog开发专属交互窗口),全程搭配完整可运行代码,新手也能轻松掌握! mjzpc9v3.png图片 一、先明确:对话框的核心概念 在学习具体用法前,先理清对话框的两个核心属性,避免用错场景: 1. 模态(Modal)vs 非模态(Non-Modal) 模态对话框:弹出后阻塞主窗口操作,必须先处理对话框才能回到主窗口(如登录弹窗、确认删除提示),是最常用的类型; 非模态对话框:弹出后不阻塞主窗口,可同时操作主窗口和对话框(如悬浮的工具面板)。 2. 标准对话框 vs 自定义对话框 标准对话框:PyQt5预制的通用对话框(QMessageBox、QFileDialog、QColorDialog等),无需自定义界面,直接调用即可,开发效率高; 自定义对话框:基于QDialog类开发的专属对话框(如登录窗口、高级设置窗口),可自由设计界面和交互逻辑。 二、标准对话框详解:直接调用的通用交互窗口 PyQt5提供了一系列开箱即用的标准对话框,覆盖80%的通用交互场景,重点掌握以下5类: 1. QMessageBox:消息提示对话框(最常用) 用于显示提示、警告、错误、确认等信息,支持自定义按钮和交互逻辑。 效果图 信息提示框 - mjzoqu7y.png图片 警告提示框 - mjzos4d4.png图片 错误提示框 - mjzosqpr.png图片 确认提示框 - mjzot5q2.png图片 自定义提示框 - mjzp1z0h.png图片 完整代码:QMessageBox常用类型 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QPushButton, QMessageBox ) class MessageBoxDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QMessageBox标准消息框演示") self.resize(400, 300) # 布局与按钮 layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(50, 50, 50, 50) # 不同类型的消息框按钮 info_btn = QPushButton("信息提示框") warn_btn = QPushButton("警告提示框") error_btn = QPushButton("错误提示框") question_btn = QPushButton("确认对话框") custom_btn = QPushButton("自定义按钮对话框") # 绑定信号 info_btn.clicked.connect(self.show_info) warn_btn.clicked.connect(self.show_warn) error_btn.clicked.connect(self.show_error) question_btn.clicked.connect(self.show_question) custom_btn.clicked.connect(self.show_custom) # 添加到布局 for btn in [info_btn, warn_btn, error_btn, question_btn, custom_btn]: layout.addWidget(btn) self.setLayout(layout) # 1. 信息提示框(仅提示,无返回值) def show_info(self): QMessageBox.information( self, # 父窗口 "信息", # 标题 "操作成功!这是一个信息提示框。", # 内容 QMessageBox.Ok # 按钮(默认Ok) ) # 2. 警告提示框 def show_warn(self): QMessageBox.warning( self, "警告", "数据未保存,关闭窗口将丢失内容!", QMessageBox.Ok | QMessageBox.Cancel # 多个按钮 ) # 3. 错误提示框 def show_error(self): QMessageBox.critical( self, "错误", "文件读取失败,请检查文件路径是否正确!", QMessageBox.Retry | QMessageBox.Abort ) # 4. 确认对话框(带返回值,判断用户选择) def show_question(self): reply = QMessageBox.question( self, "确认", "确定要删除这条数据吗?", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, # 按钮组合 QMessageBox.No # 默认选中No按钮 ) # 判断用户点击的按钮 if reply == QMessageBox.Yes: print("用户选择:删除") elif reply == QMessageBox.No: print("用户选择:不删除") else: print("用户选择:取消") # 5. 自定义按钮对话框 def show_custom(self): # 创建自定义消息框 msg_box = QMessageBox(self) msg_box.setWindowTitle("自定义对话框") msg_box.setText("请选择操作:") # 设置自定义按钮 btn1 = msg_box.addButton("导出", QMessageBox.ActionRole) btn2 = msg_box.addButton("导入", QMessageBox.ActionRole) btn3 = msg_box.addButton("取消", QMessageBox.CancelRole) # 显示对话框并获取返回值 msg_box.exec_() # 判断用户点击的按钮 if msg_box.clickedButton() == btn1: print("用户选择:导出") elif msg_box.clickedButton() == btn2: print("用户选择:导入") else: print("用户选择:取消") if __name__ == "__main__": app = QApplication(sys.argv) window = MessageBoxDemo() window.show() sys.exit(app.exec_())QMessageBox核心要点 常用类型:information()(信息)、warning()(警告)、critical()(错误)、question()(确认); 按钮组合:用|拼接(如QMessageBox.Yes | QMessageBox.No); 返回值判断:通过返回的枚举值(如QMessageBox.Yes)判断用户操作; 自定义按钮:创建QMessageBox实例,用addButton()添加自定义按钮。 2. 其他标准对话框:文件/颜色/字体选择 除了消息框,PyQt5还提供了文件、颜色、字体选择的标准对话框,用法统一且简单: 完整代码:文件/颜色/字体对话框 效果如图 mjzp4re8.png图片 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog, QColorDialog, QFontDialog, QLabel ) from PyQt5.QtGui import QColor, QFont from PyQt5.QtCore import Qt class StandardDialogsDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("标准对话框(文件/颜色/字体)演示") self.resize(500, 300) # 布局 layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(50, 50, 50, 50) # 按钮 file_btn = QPushButton("选择文件") color_btn = QPushButton("选择颜色") font_btn = QPushButton("选择字体") # 结果显示标签 self.result_label = QLabel("选择结果将显示在这里...") self.result_label.setAlignment(Qt.AlignCenter) # 绑定信号 file_btn.clicked.connect(self.choose_file) color_btn.clicked.connect(self.choose_color) font_btn.clicked.connect(self.choose_font) # 添加到布局 layout.addWidget(file_btn) layout.addWidget(color_btn) layout.addWidget(font_btn) layout.addWidget(self.result_label) self.setLayout(layout) # 1. 文件选择对话框(打开文件) def choose_file(self): # getOpenFileName:选择单个文件 # getOpenFileNames:选择多个文件 # getSaveFileName:保存文件 file_path, _ = QFileDialog.getOpenFileName( self, # 父窗口 "选择文件", # 标题 "", # 默认路径(当前目录) "Text Files (*.txt);;Image Files (*.png *.jpg);;All Files (*.*)" # 文件筛选器 ) if file_path: self.result_label.setText(f"选中文件:{file_path}") else: self.result_label.setText("未选择任何文件") # 2. 颜色选择对话框 def choose_color(self): # 获取当前标签颜色,作为默认颜色 current_color = self.result_label.textColor() # 弹出颜色选择框 color = QColorDialog.getColor( current_color, # 默认颜色 self, "选择颜色" ) if color.isValid(): # 用户选择了有效颜色 self.result_label.setText(f"选中颜色:{color.name()}") self.result_label.setStyleSheet(f"color: {color.name()}; font-size: 16px;") # 3. 字体选择对话框 def choose_font(self): # 获取当前标签字体,作为默认字体 current_font = self.result_label.font() # 弹出字体选择框 font, ok = QFontDialog.getFont( current_font, # 默认字体 self, "选择字体" ) if ok: # 用户确认选择 self.result_label.setText(f"选中字体:{font.family()},大小:{font.pointSize()}") self.result_label.setFont(font) if __name__ == "__main__": app = QApplication(sys.argv) window = StandardDialogsDemo() window.show() sys.exit(app.exec_())核心方法总结 对话框类型核心方法关键参数/返回值QFileDialoggetOpenFileName()返回(选中路径, 筛选器),支持多文件选择(getOpenFileNames)QColorDialoggetColor()返回QColor对象,isValid()判断是否选择有效颜色QFontDialoggetFont()返回(字体对象, 是否确认),ok为True时表示用户确认选择三、自定义对话框:基于QDialog开发专属交互窗口 当标准对话框无法满足需求时(如登录验证、高级参数设置),需要基于QDialog开发自定义对话框,核心是“设计界面+处理返回值+设置模态”。 1. 自定义对话框基础:登录窗口案例 实现一个带“用户名/密码输入+登录/取消按钮”的登录对话框,支持模态显示和返回值判断: 步骤1:自定义登录对话框类 # login_dialog.py(可单独文件,也可写在主文件) from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox ) from PyQt5.QtCore import Qt class LoginDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.init_ui() # 设置模态(默认模态,也可显式设置) self.setModal(True) def init_ui(self): # 对话框基础设置 self.setWindowTitle("用户登录") self.setFixedSize(350, 200) # 固定大小,避免缩放 # 布局 layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(40, 30, 40, 30) # 用户名输入 user_layout = QHBoxLayout() user_layout.addWidget(QLabel("用户名:")) self.user_edit = QLineEdit() self.user_edit.setPlaceholderText("请输入用户名") user_layout.addWidget(self.user_edit) # 密码输入 pwd_layout = QHBoxLayout() pwd_layout.addWidget(QLabel("密 码:")) self.pwd_edit = QLineEdit() self.pwd_edit.setEchoMode(QLineEdit.Password) # 密码隐藏 self.pwd_edit.setPlaceholderText("请输入密码") pwd_layout.addWidget(self.pwd_edit) # 按钮区 btn_layout = QHBoxLayout() login_btn = QPushButton("登录") cancel_btn = QPushButton("取消") login_btn.clicked.connect(self.check_login) cancel_btn.clicked.connect(self.reject) # 关闭对话框,返回Rejected btn_layout.addWidget(login_btn) btn_layout.addWidget(cancel_btn) # 添加到布局 layout.addLayout(user_layout) layout.addLayout(pwd_layout) layout.addLayout(btn_layout) self.setLayout(layout) def check_login(self): """验证登录信息""" username = self.user_edit.text().strip() password = self.pwd_edit.text().strip() # 简单验证(实际项目中对接数据库/接口) if username == "admin" and password == "123456": # 验证通过,关闭对话框并返回Accepted self.accept() else: QMessageBox.warning( self, "登录失败", "用户名或密码错误!", QMessageBox.Ok ) # 可选:获取用户输入的信息(供主窗口调用) def get_user_info(self): return { "username": self.user_edit.text().strip(), "password": self.pwd_edit.text().strip() }步骤2:主窗口调用自定义登录对话框 import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QPushButton, QLabel ) from login_dialog import LoginDialog # 若在同一文件,无需导入 class MainWindow(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("主窗口(调用自定义登录对话框)") self.resize(400, 300) # 布局 layout = QVBoxLayout() layout.setSpacing(20) layout.setContentsMargins(50, 50, 50, 50) # 按钮和标签 login_btn = QPushButton("点击登录") self.status_label = QLabel("当前状态:未登录") self.status_label.setAlignment(Qt.AlignCenter) # 绑定信号 login_btn.clicked.connect(self.show_login_dialog) # 添加到布局 layout.addWidget(login_btn) layout.addWidget(self.status_label) self.setLayout(layout) def show_login_dialog(self): """显示登录对话框并处理返回值""" # 创建登录对话框实例 login_dlg = LoginDialog(self) # 显示对话框并获取返回值 result = login_dlg.exec_() # 判断返回值:Accepted(登录成功)/ Rejected(取消/关闭) if result == login_dlg.Accepted: user_info = login_dlg.get_user_info() self.status_label.setText(f"当前状态:已登录(用户名:{user_info['username']})") else: self.status_label.setText("当前状态:取消登录") if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec_())2. 自定义对话框核心要点 继承QDialog:自定义对话框必须继承QDialog类; 模态设置:setModal(True)设置模态(默认模态),非模态用setModal(False); 返回值处理: self.accept():关闭对话框,返回QDialog.Accepted(表示操作成功); self.reject():关闭对话框,返回QDialog.Rejected(表示取消/失败); 调用exec_()显示对话框,返回值为Accepted/Rejected; 数据传递:通过自定义方法(如get_user_info())将对话框内的输入数据传递给主窗口; 窗口大小:自定义对话框建议用setFixedSize()固定大小,避免用户缩放导致界面混乱。 四、综合案例:整合标准对话框与自定义对话框 实现一个“简易文本编辑器”,整合: 自定义登录对话框(启动时要求登录); 标准文件对话框(打开/保存文件); 标准颜色/字体对话框(设置文本样式); 标准消息框(提示操作结果)。 完整代码 mjzp8xp5.png图片 mjzp9112.png图片 import sys import os from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QColorDialog, QFontDialog, QMessageBox, QDialog, QLabel, QLineEdit ) from PyQt5.QtGui import QColor, QFont from PyQt5.QtCore import Qt # 自定义登录对话框 class LoginDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.init_ui() self.setModal(True) def init_ui(self): self.setWindowTitle("用户登录") self.setFixedSize(350, 200) layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(40, 30, 40, 30) # 用户名 user_layout = QHBoxLayout() user_layout.addWidget(QLabel("用户名:")) self.user_edit = QLineEdit() self.user_edit.setPlaceholderText("admin") user_layout.addWidget(self.user_edit) layout.addLayout(user_layout) # 密码 pwd_layout = QHBoxLayout() pwd_layout.addWidget(QLabel("密 码:")) self.pwd_edit = QLineEdit() self.pwd_edit.setEchoMode(QLineEdit.Password) self.pwd_edit.setPlaceholderText("123456") pwd_layout.addWidget(self.pwd_edit) layout.addLayout(pwd_layout) # 按钮 btn_layout = QHBoxLayout() login_btn = QPushButton("登录") cancel_btn = QPushButton("取消") login_btn.clicked.connect(self.check_login) cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(login_btn) btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) self.setLayout(layout) def check_login(self): if self.user_edit.text().strip() == "admin" and self.pwd_edit.text().strip() == "123456": self.accept() else: QMessageBox.warning(self, "失败", "用户名/密码错误!") # 主编辑器窗口 class EditorWindow(QWidget): def __init__(self): super().__init__() # 先显示登录对话框,验证失败则退出 if not self.check_login(): sys.exit() # 初始化主界面 self.init_ui() self.current_file = None def check_login(self): """显示登录对话框,返回是否登录成功""" login_dlg = LoginDialog(self) return login_dlg.exec_() == login_dlg.Accepted def init_ui(self): self.setWindowTitle("简易编辑器(整合对话框)") self.resize(800, 600) # 布局 main_layout = QVBoxLayout() main_layout.setSpacing(10) main_layout.setContentsMargins(15, 15, 15, 15) # 功能按钮区 btn_layout = QHBoxLayout() open_btn = QPushButton("打开文件") save_btn = QPushButton("保存文件") color_btn = QPushButton("字体颜色") font_btn = QPushButton("字体设置") for btn in [open_btn, save_btn, color_btn, font_btn]: btn.setFixedSize(100, 30) btn_layout.addWidget(open_btn) btn_layout.addWidget(save_btn) btn_layout.addStretch() btn_layout.addWidget(color_btn) btn_layout.addWidget(font_btn) # 文本编辑区 self.text_edit = QTextEdit() self.text_edit.setFont(QFont("微软雅黑", 12)) # 添加到布局 main_layout.addLayout(btn_layout) main_layout.addWidget(self.text_edit) self.setLayout(main_layout) # 绑定信号 open_btn.clicked.connect(self.open_file) save_btn.clicked.connect(self.save_file) color_btn.clicked.connect(self.set_font_color) font_btn.clicked.connect(self.set_font) # 打开文件(标准文件对话框) def open_file(self): file_path, _ = QFileDialog.getOpenFileName( self, "打开文件", "", "Text Files (*.txt);;All Files (*.*)" ) if file_path and os.path.exists(file_path): with open(file_path, "r", encoding="utf-8") as f: self.text_edit.setText(f.read()) self.current_file = file_path QMessageBox.information(self, "成功", f"已打开文件:{os.path.basename(file_path)}") # 保存文件(标准文件对话框) def save_file(self): if self.current_file: with open(self.current_file, "w", encoding="utf-8") as f: f.write(self.text_edit.toPlainText()) QMessageBox.information(self, "成功", "文件保存成功!") else: file_path, _ = QFileDialog.getSaveFileName( self, "保存文件", "", "Text Files (*.txt)" ) if file_path: if not file_path.endswith(".txt"): file_path += ".txt" with open(file_path, "w", encoding="utf-8") as f: f.write(self.text_edit.toPlainText()) self.current_file = file_path QMessageBox.information(self, "成功", f"文件已保存到:{file_path}") # 设置字体颜色(标准颜色对话框) def set_font_color(self): color = QColorDialog.getColor(Qt.black, self, "选择字体颜色") if color.isValid(): fmt = self.text_edit.currentCharFormat() fmt.setForeground(color) self.text_edit.mergeCurrentCharFormat(fmt) # 设置字体(标准字体对话框) def set_font(self): font, ok = QFontDialog.getFont(self.text_edit.font(), self, "选择字体") if ok: self.text_edit.setCurrentFont(font) if __name__ == "__main__": app = QApplication(sys.argv) window = EditorWindow() window.show() sys.exit(app.exec_())五、常见问题排查 1. 对话框相关问题 问题1:自定义对话框非模态,主窗口可操作 → 解决:调用setModal(True),或用exec_()显示(show()显示为非模态); 问题2:文件对话框选择中文路径乱码 → 解决:读写文件时指定encoding="utf-8",Windows系统可尝试encoding="gbk"; 问题3:登录对话框关闭后主窗口也退出 → 解决:主窗口初始化时,登录失败调用sys.exit()需确保是在app创建后; 问题4:标准对话框标题/按钮显示英文 → 解决:PyQt5默认是英文,可通过设置Qt的语言环境(需额外配置翻译文件),或使用自定义对话框替换。 2. 模态对话框的坑 exec_():显示模态对话框,阻塞主线程,返回值为Accepted/Rejected; show():显示非模态对话框,不阻塞主线程,无返回值; 自定义对话框若用show()显示,需手动设置setModal(True)才能变为模态。 总结 标准对话框:PyQt5预制的通用交互窗口(消息框、文件选择框等),直接调用即可,覆盖80%通用场景; 自定义对话框:继承QDialog开发专属窗口,核心是setModal()设置模态、accept()/reject()处理返回值; 模态vs非模态:模态对话框阻塞主窗口(常用),非模态不阻塞(适合悬浮面板); 综合应用:实际项目中常结合标准对话框和自定义对话框,比如登录验证+文件操作+消息提示。 下一章我们将学习PyQt5的容器控件(QTabWidget、QGroupBox),实现多标签页、分组管理的复杂界面,进一步提升界面的层次感和实用性。如果在对话框开发中遇到问题,或者想拓展更复杂的自定义对话框(如带验证码的登录窗口),欢迎在评论区留言讨论~ -
PyQt5文本与表格控件:QTextEdit与QTableWidget(附实战代码) 第7篇:PyQt5文本与表格控件:QTextEdit与QTableWidget(完整代码) 哈喽~ 欢迎来到PyQt5系列的第7篇!上一章我们掌握了网格布局和表单布局,解决了复杂界面的排版问题。今天我们聚焦两个高频实用控件:QTextEdit(多行富文本编辑控件)和QTableWidget(表格控件)——这两个控件是处理“大段文本”和“结构化数据”的核心,比如文本编辑器、数据管理系统、报表展示等场景都离不开它们。全程搭配完整可运行代码,新手也能轻松掌握! mjxm4a2v.png图片 一、先明确:两个控件的核心定位 在学习具体用法前,先理清这两个控件的适用场景,避免用错: QTextEdit:多行文本编辑/展示控件,支持富文本(字体、颜色、图片、超链接),比QLineEdit(单行)功能强大,核心用于“大段文本处理”(如记事本、富文本编辑器); QTableWidget:表格控件,支持行/列的增删改查、单元格编辑、数据排序,核心用于“结构化数据展示/编辑”(如Excel简易表格、用户信息列表)。 二、QTextEdit详解:从基础文本到富文本编辑 QTextEdit是阶段一“简易记事本”中用到的核心控件,但当时只用到了基础文本功能,这一节我们深挖它的富文本能力。 1. QTextEdit基础用法(完整代码) 先回顾基础功能,再拓展富文本设置: import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QTextEdit, QVBoxLayout, QPushButton, QHBoxLayout, QColorDialog, QFontDialog ) from PyQt5.QtGui import QTextCharFormat, QFont, QColor from PyQt5.QtCore import Qt class TextEditDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QTextEdit基础与富文本演示") self.resize(600, 400) # 1. 主布局(垂直):按钮区 + 文本编辑区 main_layout = QVBoxLayout() main_layout.setSpacing(10) main_layout.setContentsMargins(15, 15, 15, 15) # 2. 功能按钮区(水平布局) btn_layout = QHBoxLayout() btn_layout.setSpacing(8) # 富文本功能按钮 self.bold_btn = QPushButton("加粗") self.italic_btn = QPushButton("斜体") self.color_btn = QPushButton("字体颜色") self.font_btn = QPushButton("选择字体") self.clear_btn = QPushButton("清空文本") # 按钮样式 btn_size = (80, 30) for btn in [self.bold_btn, self.italic_btn, self.color_btn, self.font_btn, self.clear_btn]: btn.setFixedSize(*btn_size) # 添加到按钮布局 btn_layout.addWidget(self.bold_btn) btn_layout.addWidget(self.italic_btn) btn_layout.addWidget(self.color_btn) btn_layout.addWidget(self.font_btn) btn_layout.addStretch() # 伸缩空间 btn_layout.addWidget(self.clear_btn) # 3. 创建QTextEdit控件 self.text_edit = QTextEdit() # 基础设置:默认提示文本、字体 self.text_edit.setPlaceholderText("请输入文本(支持富文本格式:加粗、斜体、改颜色等)") self.text_edit.setFont(QFont("微软雅黑", 12)) # 允许自动换行 self.text_edit.setLineWrapMode(QTextEdit.WidgetWidth) # 4. 添加到主布局 main_layout.addLayout(btn_layout) main_layout.addWidget(self.text_edit) # 5. 绑定布局和信号 self.setLayout(main_layout) self.bind_signals() def bind_signals(self): """绑定信号与槽""" self.bold_btn.clicked.connect(self.set_bold) self.italic_btn.clicked.connect(self.set_italic) self.color_btn.clicked.connect(self.set_font_color) self.font_btn.clicked.connect(self.choose_font) self.clear_btn.clicked.connect(self.text_edit.clear) def set_bold(self): """设置选中文本加粗/取消加粗""" # 获取当前光标选中的文本格式 fmt = QTextCharFormat() # 切换加粗状态:当前加粗则取消,反之则加粗 fmt.setFontWeight(QFont.Bold if self.text_edit.fontWeight() != QFont.Bold else QFont.Normal) # 应用格式到选中文本 self.text_edit.mergeCurrentCharFormat(fmt) def set_italic(self): """设置选中文本斜体/取消斜体""" fmt = QTextCharFormat() fmt.setFontItalic(not self.text_edit.fontItalic()) self.text_edit.mergeCurrentCharFormat(fmt) def set_font_color(self): """选择字体颜色并应用到选中文本""" # 弹出颜色选择对话框 color = QColorDialog.getColor(Qt.black, self, "选择字体颜色") if color.isValid(): # 用户选择了有效颜色 fmt = QTextCharFormat() fmt.setForeground(color) self.text_edit.mergeCurrentCharFormat(fmt) def choose_font(self): """选择字体(字体名、大小、样式)""" # 弹出字体选择对话框 font, ok = QFontDialog.getFont(self.text_edit.font(), self, "选择字体") if ok: # 用户确认选择 self.text_edit.setCurrentFont(font) if __name__ == "__main__": app = QApplication(sys.argv) window = TextEditDemo() window.show() sys.exit(app.exec_())2. QTextEdit核心方法解析 QTextEdit的核心分为“文本操作”和“格式设置”两类,重点掌握以下方法: (1)基础文本操作 方法作用toPlainText()获取纯文本内容(忽略富文本格式)setPlainText(文本)设置纯文本内容(覆盖原有内容)toHtml()获取富文本内容(HTML格式)setHtml(HTML文本)设置富文本内容(支持HTML标签)append(文本)在末尾追加文本(保留原有格式)clear()清空所有文本undo()/redo()撤销/重做操作(2)富文本格式设置 方法作用mergeCurrentCharFormat(格式对象)将格式应用到选中的文本setCurrentFont(字体对象)设置当前光标位置/选中文本的字体setFontWeight(QFont.Bold/Normal)设置加粗/取消加粗setFontItalic(True/False)设置斜体/取消斜体setForeground(QColor)设置字体颜色3. QTextEdit实战:简易富文本编辑器 基于基础用法,拓展一个带“打开/保存富文本”功能的编辑器,支持保存为HTML格式(保留富文本样式): import sys import os from PyQt5.QtWidgets import ( QApplication, QWidget, QTextEdit, QVBoxLayout, QPushButton, QHBoxLayout, QColorDialog, QFontDialog, QFileDialog ) from PyQt5.QtGui import QTextCharFormat, QFont, QColor from PyQt5.QtCore import Qt class RichTextEditor(QWidget): def __init__(self): super().__init__() self.init_ui() self.current_file_path = None # 记录当前文件路径 def init_ui(self): self.setWindowTitle("简易富文本编辑器") self.resize(800, 600) # 1. 主布局 main_layout = QVBoxLayout() main_layout.setSpacing(10) main_layout.setContentsMargins(15, 15, 15, 15) # 2. 顶部功能按钮区 top_btn_layout = QHBoxLayout() self.new_btn = QPushButton("新建") self.open_btn = QPushButton("打开") self.save_btn = QPushButton("保存") for btn in [self.new_btn, self.open_btn, self.save_btn]: btn.setFixedSize(80, 30) top_btn_layout.addWidget(self.new_btn) top_btn_layout.addWidget(self.open_btn) top_btn_layout.addWidget(self.save_btn) top_btn_layout.addStretch() # 3. 格式设置按钮区 fmt_btn_layout = QHBoxLayout() self.bold_btn = QPushButton("加粗") self.italic_btn = QPushButton("斜体") self.color_btn = QPushButton("字体颜色") self.font_btn = QPushButton("选择字体") for btn in [self.bold_btn, self.italic_btn, self.color_btn, self.font_btn]: btn.setFixedSize(80, 30) fmt_btn_layout.addWidget(self.bold_btn) fmt_btn_layout.addWidget(self.italic_btn) fmt_btn_layout.addWidget(self.color_btn) fmt_btn_layout.addWidget(self.font_btn) fmt_btn_layout.addStretch() # 4. 文本编辑区 self.text_edit = QTextEdit() self.text_edit.setFont(QFont("微软雅黑", 12)) # 5. 添加到主布局 main_layout.addLayout(top_btn_layout) main_layout.addLayout(fmt_btn_layout) main_layout.addWidget(self.text_edit) # 6. 绑定布局和信号 self.setLayout(main_layout) self.bind_signals() def bind_signals(self): """绑定所有信号与槽""" # 文件操作 self.new_btn.clicked.connect(self.on_new) self.open_btn.clicked.connect(self.on_open) self.save_btn.clicked.connect(self.on_save) # 格式操作 self.bold_btn.clicked.connect(self.set_bold) self.italic_btn.clicked.connect(self.set_italic) self.color_btn.clicked.connect(self.set_font_color) self.font_btn.clicked.connect(self.choose_font) # ---------- 文件操作槽函数 ---------- def on_new(self): """新建文件:清空文本,重置路径""" self.text_edit.clear() self.current_file_path = None self.setWindowTitle("简易富文本编辑器 - 未保存文件") def on_open(self): """打开文件:支持txt(纯文本)和html(富文本)""" file_path, _ = QFileDialog.getOpenFileName( self, "打开文件", "", "HTML Files (*.html);;Text Files (*.txt);;All Files (*.*)" ) if file_path and os.path.exists(file_path): self.current_file_path = file_path # 根据后缀选择读取方式 if file_path.endswith(".html"): with open(file_path, "r", encoding="utf-8") as f: content = f.read() self.text_edit.setHtml(content) else: with open(file_path, "r", encoding="utf-8") as f: content = f.read() self.text_edit.setPlainText(content) # 更新窗口标题 self.setWindowTitle(f"简易富文本编辑器 - {os.path.basename(file_path)}") def on_save(self): """保存文件:默认保存为HTML(保留富文本格式)""" if self.current_file_path: # 直接保存 self.save_file(self.current_file_path) else: # 弹出保存对话框 file_path, _ = QFileDialog.getSaveFileName( self, "保存文件", "", "HTML Files (*.html);;Text Files (*.txt)" ) if file_path: # 补充后缀 if not (file_path.endswith(".html") or file_path.endswith(".txt")): file_path += ".html" self.save_file(file_path) self.current_file_path = file_path self.setWindowTitle(f"简易富文本编辑器 - {os.path.basename(file_path)}") def save_file(self, file_path): """辅助函数:保存文件""" if file_path.endswith(".html"): content = self.text_edit.toHtml() else: content = self.text_edit.toPlainText() with open(file_path, "w", encoding="utf-8") as f: f.write(content) # ---------- 格式设置槽函数 ---------- def set_bold(self): fmt = QTextCharFormat() fmt.setFontWeight(QFont.Bold if self.text_edit.fontWeight() != QFont.Bold else QFont.Normal) self.text_edit.mergeCurrentCharFormat(fmt) def set_italic(self): fmt = QTextCharFormat() fmt.setFontItalic(not self.text_edit.fontItalic()) self.text_edit.mergeCurrentCharFormat(fmt) def set_font_color(self): color = QColorDialog.getColor(Qt.black, self, "选择字体颜色") if color.isValid(): fmt = QTextCharFormat() fmt.setForeground(color) self.text_edit.mergeCurrentCharFormat(fmt) def choose_font(self): font, ok = QFontDialog.getFont(self.text_edit.font(), self, "选择字体") if ok: self.text_edit.setCurrentFont(font) if __name__ == "__main__": app = QApplication(sys.argv) editor = RichTextEditor() editor.show() sys.exit(app.exec_())三、QTableWidget详解:结构化数据的展示与编辑 QTableWidget是处理表格数据的核心控件,支持行/列管理、单元格编辑、数据排序,是实现“数据表格”的首选。 1. QTableWidget基础用法(完整代码) 先实现一个基础表格,演示行/列添加、单元格赋值、选中行获取等核心操作: import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QPushButton, QMessageBox ) from PyQt5.QtCore import Qt class TableWidgetDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QTableWidget基础演示") self.resize(600, 400) # 1. 主布局:按钮区 + 表格区 main_layout = QVBoxLayout() main_layout.setSpacing(15) main_layout.setContentsMargins(15, 15, 15, 15) # 2. 功能按钮区 btn_layout = QHBoxLayout() self.add_row_btn = QPushButton("添加行") self.del_row_btn = QPushButton("删除选中行") self.get_data_btn = QPushButton("获取选中行数据") self.clear_btn = QPushButton("清空表格") for btn in [self.add_row_btn, self.del_row_btn, self.get_data_btn, self.clear_btn]: btn.setFixedSize(100, 30) btn_layout.addWidget(self.add_row_btn) btn_layout.addWidget(self.del_row_btn) btn_layout.addWidget(self.get_data_btn) btn_layout.addWidget(self.clear_btn) btn_layout.addStretch() # 3. 创建QTableWidget控件 self.table = QTableWidget() # 设置表格行列数:5行4列 self.table.setRowCount(5) self.table.setColumnCount(4) # 设置列标题 self.table.setHorizontalHeaderLabels(["ID", "姓名", "性别", "年龄"]) # 可选设置:列宽自适应、行高自适应、禁止编辑(默认允许) self.table.horizontalHeader().setStretchLastSection(True) # 最后一列拉伸 self.table.verticalHeader().setVisible(False) # 隐藏行号 # self.table.setEditTriggers(QTableWidget.NoEditTriggers) # 禁止编辑单元格 # 4. 填充初始数据 init_data = [ [1, "张三", "男", 25], [2, "李四", "女", 28], [3, "王五", "男", 30], [4, "赵六", "女", 22], [5, "钱七", "男", 27] ] for row in range(len(init_data)): for col in range(len(init_data[row])): # 创建表格项(设置居中对齐) item = QTableWidgetItem(str(init_data[row][col])) item.setTextAlignment(Qt.AlignCenter) # 将项添加到表格 self.table.setItem(row, col, item) # 5. 添加到主布局 main_layout.addLayout(btn_layout) main_layout.addWidget(self.table) # 6. 绑定布局和信号 self.setLayout(main_layout) self.bind_signals() def bind_signals(self): """绑定信号与槽""" self.add_row_btn.clicked.connect(self.add_row) self.del_row_btn.clicked.connect(self.del_selected_row) self.get_data_btn.clicked.connect(self.get_selected_data) self.clear_btn.clicked.connect(self.clear_table) def add_row(self): """添加一行空数据""" # 获取当前行数,在末尾添加新行 current_row = self.table.rowCount() self.table.insertRow(current_row) # 给新行ID列赋值(自增) id_item = QTableWidgetItem(str(current_row + 1)) id_item.setTextAlignment(Qt.AlignCenter) self.table.setItem(current_row, 0, id_item) def del_selected_row(self): """删除选中的行""" # 获取选中的行号(单个选中) selected_rows = self.table.selectedItems() if not selected_rows: QMessageBox.warning(self, "提示", "请先选中要删除的行!") return # 获取选中行的行号(取第一个选中项的行号) row = selected_rows[0].row() self.table.removeRow(row) def get_selected_data(self): """获取选中行的所有数据""" selected_rows = self.table.selectedItems() if not selected_rows: QMessageBox.warning(self, "提示", "请先选中一行!") return # 提取选中行的所有列数据 row = selected_rows[0].row() data = [] for col in range(self.table.columnCount()): item = self.table.item(row, col) data.append(item.text() if item else "") # 显示数据 QMessageBox.information(self, "选中行数据", f"ID:{data[0]}\n姓名:{data[1]}\n性别:{data[2]}\n年龄:{data[3]}") def clear_table(self): """清空表格(保留列标题)""" self.table.setRowCount(0) if __name__ == "__main__": app = QApplication(sys.argv) window = TableWidgetDemo() window.show() sys.exit(app.exec_())2. QTableWidget核心方法解析 QTableWidget的核心围绕“行/列管理”和“单元格操作”,重点掌握: (1)行列管理 方法作用setRowCount(行数)设置表格行数setColumnCount(列数)设置表格列数setHorizontalHeaderLabels([列标题列表])设置列标题insertRow(行号)在指定行号位置插入新行removeRow(行号)删除指定行setRowCount(0)清空所有行(保留列标题)(2)单元格操作 方法作用setItem(行号, 列号, QTableWidgetItem)给指定单元格设置内容item(行号, 列号)获取指定单元格的项selectedItems()获取所有选中的单元格项setEditTriggers(触发方式)设置单元格编辑触发方式(如NoEditTriggers禁止编辑)setTextAlignment(对齐方式)设置单元格文本对齐(如Qt.AlignCenter居中)(3)样式/布局优化 方法作用horizontalHeader().setStretchLastSection(True)最后一列自适应拉伸verticalHeader().setVisible(False)隐藏行号setColumnWidth(列号, 宽度)设置指定列的宽度setRowHeight(行号, 高度)设置指定行的高度3. QTableWidget实战:简易数据表格管理器 拓展一个带“导入/导出数据”(CSV格式)、“数据排序”功能的表格管理器,贴合实际数据管理场景: import sys import os import csv from PyQt5.QtWidgets import ( QApplication, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QPushButton, QMessageBox, QFileDialog, QComboBox ) from PyQt5.QtCore import Qt class TableManager(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("简易数据表格管理器") self.resize(700, 500) # 1. 主布局 main_layout = QVBoxLayout() main_layout.setSpacing(15) main_layout.setContentsMargins(15, 15, 15, 15) # 2. 顶部功能区(导入/导出 + 排序) top_layout = QHBoxLayout() # 导入/导出按钮 self.import_btn = QPushButton("导入CSV") self.export_btn = QPushButton("导出CSV") # 排序下拉框 self.sort_combo = QComboBox() self.sort_combo.addItems(["按ID升序", "按年龄升序", "按年龄降序"]) self.sort_btn = QPushButton("排序") # 样式设置 for btn in [self.import_btn, self.export_btn, self.sort_btn]: btn.setFixedSize(100, 30) self.sort_combo.setFixedWidth(120) # 添加到布局 top_layout.addWidget(self.import_btn) top_layout.addWidget(self.export_btn) top_layout.addStretch() top_layout.addWidget(self.sort_combo) top_layout.addWidget(self.sort_btn) # 3. 表格区 self.table = QTableWidget() # 初始列标题 self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(["ID", "姓名", "性别", "年龄"]) # 布局优化 self.table.horizontalHeader().setStretchLastSection(True) self.table.verticalHeader().setVisible(False) # 4. 添加到主布局 main_layout.addLayout(top_layout) main_layout.addWidget(self.table) # 5. 绑定信号 self.setLayout(main_layout) self.bind_signals() def bind_signals(self): self.import_btn.clicked.connect(self.import_csv) self.export_btn.clicked.connect(self.export_csv) self.sort_btn.clicked.connect(self.sort_data) # ---------- CSV导入导出 ---------- def import_csv(self): """导入CSV文件到表格""" file_path, _ = QFileDialog.getOpenFileName( self, "导入CSV文件", "", "CSV Files (*.csv);;All Files (*.*)" ) if not file_path: return try: # 清空原有数据 self.table.setRowCount(0) # 读取CSV文件 with open(file_path, "r", encoding="utf-8") as f: reader = csv.reader(f) # 跳过表头(如果CSV有表头) # next(reader) # 逐行添加数据 for row_idx, row_data in enumerate(reader): self.table.insertRow(row_idx) for col_idx, col_data in enumerate(row_data): item = QTableWidgetItem(col_data) item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row_idx, col_idx, item) QMessageBox.information(self, "成功", "CSV文件导入成功!") except Exception as e: QMessageBox.critical(self, "错误", f"导入失败:{str(e)}") def export_csv(self): """导出表格数据到CSV""" file_path, _ = QFileDialog.getSaveFileName( self, "导出CSV文件", "", "CSV Files (*.csv)" ) if not file_path: return # 补充.csv后缀 if not file_path.endswith(".csv"): file_path += ".csv" try: # 写入CSV文件 with open(file_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) # 写入表头 headers = [self.table.horizontalHeaderItem(col).text() for col in range(self.table.columnCount())] writer.writerow(headers) # 写入数据 for row in range(self.table.rowCount()): row_data = [] for col in range(self.table.columnCount()): item = self.table.item(row, col) row_data.append(item.text() if item else "") writer.writerow(row_data) QMessageBox.information(self, "成功", "CSV文件导出成功!") except Exception as e: QMessageBox.critical(self, "错误", f"导出失败:{str(e)}") # ---------- 数据排序 ---------- def sort_data(self): """按选择的规则排序""" sort_type = self.sort_combo.currentText() if sort_type == "按ID升序": # 按第0列(ID)升序排序 self.table.sortItems(0, Qt.AscendingOrder) elif sort_type == "按年龄升序": # 按第3列(年龄)升序排序 self.table.sortItems(3, Qt.AscendingOrder) elif sort_type == "按年龄降序": # 按第3列(年龄)降序排序 self.table.sortItems(3, Qt.DescendingOrder) if __name__ == "__main__": app = QApplication(sys.argv) manager = TableManager() manager.show() sys.exit(app.exec_())四、综合案例:文本+表格的信息管理窗口 整合QTextEdit和QTableWidget,实现一个“用户信息管理窗口”——表格展示用户列表,选中用户后在文本区显示详情,支持编辑详情并保存: import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QPushButton, QTextEdit, QMessageBox, QSplitter ) from PyQt5.QtCore import Qt, QSize class InfoManager(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("用户信息管理窗口(文本+表格综合)") self.resize(800, 500) # 1. 创建分割器(左右布局,可拖动调整宽度) splitter = QSplitter(Qt.Horizontal) # 2. 左侧表格区 self.table = QTableWidget() self.table.setColumnCount(3) self.table.setHorizontalHeaderLabels(["ID", "姓名", "性别"]) self.table.horizontalHeader().setStretchLastSection(True) self.table.verticalHeader().setVisible(False) # 填充测试数据 test_data = [[1, "张三", "男"], [2, "李四", "女"], [3, "王五", "男"]] for row in range(len(test_data)): self.table.insertRow(row) for col in range(len(test_data[row])): item = QTableWidgetItem(str(test_data[row][col])) item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, col, item) # 3. 右侧文本区(详情编辑) right_widget = QWidget() right_layout = QVBoxLayout(right_widget) # 文本编辑区 self.info_edit = QTextEdit() self.info_edit.setPlaceholderText("选中表格中的用户,查看/编辑详情...") self.info_edit.setFont(QtGui.QFont("微软雅黑", 11)) # 保存按钮 save_btn = QPushButton("保存详情") save_btn.setFixedSize(100, 30) # 添加到右侧布局 right_layout.addWidget(self.info_edit) right_layout.addWidget(save_btn, alignment=Qt.AlignCenter) # 4. 将表格和右侧控件添加到分割器 splitter.addWidget(self.table) splitter.addWidget(right_widget) # 设置分割器初始宽度比例 splitter.setSizes([300, 500]) # 5. 主布局 main_layout = QVBoxLayout() main_layout.addWidget(splitter) self.setLayout(main_layout) # 6. 绑定信号 self.table.clicked.connect(self.show_user_info) save_btn.clicked.connect(self.save_user_info) def show_user_info(self): """选中表格行,显示用户详情""" selected_items = self.table.selectedItems() if not selected_items: return row = selected_items[0].row() # 获取用户基础信息 user_id = self.table.item(row, 0).text() name = self.table.item(row, 1).text() gender = self.table.item(row, 2).text() # 构造详情文本 info = f"""用户详情(ID:{user_id}) 姓名:{name} 性别:{gender} 年龄:暂未填写 电话:暂未填写 邮箱:暂未填写 备注:无 """ self.info_edit.setPlainText(info) def save_user_info(self): """保存编辑后的详情""" if not self.info_edit.toPlainText(): QMessageBox.warning(self, "提示", "详情不能为空!") return QMessageBox.information(self, "成功", "用户详情保存成功!") # 实际项目中可将详情写入数据库/文件 if __name__ == "__main__": app = QApplication(sys.argv) manager = InfoManager() manager.show() sys.exit(app.exec_())五、常见问题排查 1. QTextEdit相关问题 问题1:富文本格式设置不生效 → 解决:确保先选中文本再点击格式按钮;检查mergeCurrentCharFormat是否正确传入格式对象; 问题2:保存的HTML文件打开乱码 → 解决:保存时指定encoding="utf-8"; 问题3:文本编辑区不自适应窗口缩放 → 解决:确保文本编辑区直接添加到垂直布局,未设置固定大小。 2. QTableWidget相关问题 问题1:表格数据显示不全 → 解决:用horizontalHeader().setStretchLastSection(True)让最后一列拉伸;或手动设置列宽setColumnWidth; 问题2:选中行获取不到数据 → 解决:selectedItems()返回的是选中的单元格列表,需通过item.row()获取行号,再遍历列获取整行数据; 问题3:CSV导入/导出乱码 → 解决:读写CSV时指定encoding="utf-8";Windows系统可尝试encoding="gbk"; 问题4:单元格无法编辑 → 解决:检查setEditTriggers是否设置为NoEditTriggers,如需编辑改为DoubleClicked或AllEditTriggers。 总结 QTextEdit:核心用于多行文本/富文本处理,支持纯文本和HTML格式,mergeCurrentCharFormat是设置富文本格式的核心方法; QTableWidget:核心用于结构化数据展示/编辑,重点掌握行列管理、单元格操作、数据导入导出; 两个控件常结合使用(如表格展示列表+文本区展示详情),是数据管理类应用的核心组合; 下一章我们将学习PyQt5的对话框控件(QDialog),包括标准对话框和自定义对话框,进一步完善界面的交互能力。 如果在实操中遇到文本/表格处理的问题,或者想拓展更复杂的功能(如表格单元格合并、富文本插入图片),欢迎在评论区留言讨论~ -
YPay源支付云端一键部署代码 对应CentOS/Ubuntu/Debian系统 请勿购买,目前源已失效,等待我镜像一份 YPay源支付云端部署:3套专属代码对应不同系统,零失误实操 YPay源支付云端的一键部署脚本,针对CentOS、Ubuntu、Debian这三类常用Linux系统做了适配优化,3套代码对应不同环境,能避免“系统不兼容导致部署失败”的问题。下面分系统讲清每套代码的用法: mjx1ckkw.png图片 一、先明确3套代码的适用场景 代码序号适配系统核心特点代码1CentOS 7/8/9适配RPM包管理,部署速度快代码2Ubuntu 18.04+/20.04+适配DEB包管理,兼容性强代码3Debian 10+/11+轻量系统专属,资源占用低二、分系统实操:3套代码的部署步骤 场景1:CentOS系统(用代码1) 操作步骤: 以root用户登录服务器终端,执行专属部署代码: 隐藏内容,请前往内页查看详情 若提示“wget未安装”,先补装依赖: 隐藏内容,请前往内页查看详情 后续选择部署选项、等待完成的流程,和之前教程一致(新手选“01. 一键自动安装云端”)。 场景2:Ubuntu系统(用代码2) 操作步骤: 以root用户登录服务器终端,执行专属部署代码: 隐藏内容,请前往内页查看详情 若提示“wget未安装”,先补装依赖: 隐藏内容,请前往内页查看详情 同样选择对应部署选项,等待脚本自动完成环境配置、服务启动。 场景3:Debian系统(用代码3) 操作步骤: 以root用户登录服务器终端,执行专属部署代码: 隐藏内容,请前往内页查看详情 若提示“wget未安装”,先补装依赖: 隐藏内容,请前往内页查看详情 选择部署选项后,脚本会适配Debian的轻量特性,优先占用少的资源完成部署。 三、3套代码的通用注意事项 权限必须是root:非root用户会提示“无法创建目录/启动服务”,需切换root或加sudo前缀。 网络要稳定:若下载脚本卡住,可换国内镜像源(比如CentOS换阿里云源)后重试。 端口统一检查:不管用哪套代码,部署完成后都要通过“09. 一键查看防火墙状态”确认端口开放(默认常用端口是8080、8888)。 四、评论区补充的跨系统避坑点 CentOS用户:若提示“yum仓库不可用”,先执行yum clean all && yum makecache更新仓库。 Ubuntu/Debian用户:若提示“apt锁占用”,执行rm /var/lib/dpkg/lock-frontend解锁后重试。 这样根据自己的服务器系统选对应的代码,就能彻底避免“代码不匹配导致部署失败”的问题,零基础也能精准完成YPay源支付云端的搭建~ -
秒解再续前缘主题百度统计 “没有当前站点”:域名适配优化教程 秒解joe再续前缘百度统计“没有当前站点”:域名适配优化指南 很多使用joe再续前缘的站长,在百度统计配置中都会踩一个坑——明明API密钥、站点信息都填对了,前端却始终显示“没有当前站点”,导致统计数据无法正常加载。其实这不是配置问题,而是主题默认的域名匹配逻辑过于严格,今天就用一篇结构化教程,带大家一步到位解决问题! mjw77r4q.png图片 一、问题直击:你是否遇到这些现象? 主题后台已完整填写百度统计API的access_token、refresh_token、client_id等信息; 百度统计后台已添加当前站点,且状态正常; 前端页面统计区域显示“没有当前站点”,无任何统计数据展示; 排查后发现,百度统计API能正常返回站点列表,但主题无法匹配到当前域名。 二、问题根源:域名匹配逻辑的“严格性”陷阱 joe再续前缘中🐔🐔航写的默认的百度统计代码中,域名匹配采用“完全字符一致”规则,但实际场景中很容易出现格式差异: 百度统计API返回域名:www.xxx.com,主题JOE_DOMAIN常量:xxx.com; 百度统计API返回域名:http://xxx.com,主题JOE_DOMAIN常量:https://xxx.com; 域名大小写差异(如XXX.com与xxx.com)、端口号冗余等。 这些细微格式差异,会让主题代码判定“无匹配站点”,最终返回错误提示。核心需求是:让域名匹配具备“容错性”,忽略无关格式差异。 三、解决方案:3步替换优化代码 3.1 准备工作:明确文件路径 操作前先确认文件位置,避免找错文件: 服务器环境:宝塔面板(可视化操作更高效); 核心路径:网站根目录 → usr/themes/joe/(若主题文件夹重命名,对应修改); 目标文件:通常为 core/function.php 或 api.php(含“没有当前站点”文本的文件)。 3.2 第一步:定位目标代码块 登录宝塔面板,进入网站根目录,导航至 usr/themes/joe/; 点击文件管理器右上角“搜索”,输入关键词“没有当前站点”; 搜索结果中打开对应文件,找到 public static function baiduStatistic($self) 方法——该方法即为百度统计核心逻辑,从方法定义到结束大括号 } 为完整代码块。 3.3 第二步:替换为优化后代码 将以下优化后的代码,完整替换原 baiduStatistic 方法(删除原有代码,粘贴新代码): // 百度统计展示 public static function baiduStatistic($self) { $normalizeDomain = function ($domain) { $domain = preg_replace('/^https?:\/\//', '', $domain); $domain = preg_replace('/^www\./', '', $domain); return strtolower(trim($domain)); }; $statistics_config = \joe\baidu_statistic_config(); if (!is_array($statistics_config)) { return (array('access_token' => 'off')); } if (empty($statistics_config['access_token'])) { return (array('access_token' => 'off')); } $baidu_list = function () use ($statistics_config, $self) { $url = 'https://openapi.baidu.com/rest/2.0/tongji/config/getSiteList?access_token=' . $statistics_config['access_token']; $data = json_decode(file_get_contents($url), true); if (isset($data['error_code'])) { if ($data['error_code'] == 111) { $refresh_token = \network\http\get('http://openapi.baidu.com/oauth/2.0/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $statistics_config['refresh_token'], 'client_id' => $statistics_config['client_id'], 'client_secret' => $statistics_config['client_secret'] ])->toArray(); if (is_array($refresh_token)) { $theme_options = self::$options->__get('theme:' . THEME_NAME); if (empty($theme_options)) return (['message' => '请更新您的 access_token']); $backup_field = 'theme:' . THEME_NAME . '_backup'; $backup = Db::name('options')->where('name', $backup_field)->find(); if ($backup) { Db::name('options')->where('name', $backup_field)->update(['value' => $theme_options]); } else { Db::name('options')->where('name', $backup_field)->insert(['user' => '0', 'name' => $backup_field, 'value' => $theme_options]); } $theme_options = unserialize($theme_options); $theme_options['baidu_statistics'] = trim($refresh_token['access_token']) . "\r\n" . trim($refresh_token['refresh_token']) . "\r\n" . $statistics_config['client_id'] . "\r\n" . $statistics_config['client_secret']; $options_update = Db::name('options')->where('name', 'theme:' . THEME_NAME)->update(['value' => serialize($theme_options)]); if ($options_update) { $statistics_config['access_token'] = $refresh_token['access_token']; $new_url = 'https://openapi.baidu.com/rest/2.0/tongji/config/getSiteList?access_token=' . $statistics_config['access_token']; $new_data = json_decode(file_get_contents($new_url), true); return $new_data['list'] ?? []; } else { return ['message' => 'access_token 更新失败!']; } } else { return ['message' => '请更新您的 access_token']; } } return $data; } return $data['list']; }; $web_metrics = function ($site_id, $start_date, $end_date) use ($statistics_config) { $access_token = $statistics_config['access_token']; $url = "https://openapi.baidu.com/rest/2.0/tongji/report/getData?access_token=$access_token&site_id=$site_id&method=trend/time/a&start_date=$start_date&end_date=$end_date&metrics=avg_visit_time,ip_count,pv_count,&gran=day"; $data = \network\http\post($url)->toArray(); if (is_array($data)) { $data = $data['result']['sum'][0]; } else { $data = 0; } return $data; }; $list = $baidu_list(); if (!is_array($list)) { return $list; } for ($i = 0; $i < count($list); $i++) { if ($normalizeDomain($list[$i]['domain']) === $normalizeDomain(JOE_DOMAIN)) { $list = $list[$i]; break; } } if (is_array($list) && isset($list['domain'])) { $today = $web_metrics($list['site_id'], date('Ymd'), date('Ymd')); $yesterday = $web_metrics($list['site_id'], date('Ymd', strtotime("-1 days")), date('Ymd', strtotime("-1 days"))); $moon = $web_metrics($list['site_id'], date('Ym') . '01', date('Ymd')); $data = [ 'code' => 200, 'today' => $today, 'yesterday' => $yesterday, 'month' => $moon ]; return ($data); } else { return ['message' => '没有当前站点']; } }3.4 第三步:保存生效 点击文件编辑器右上角“保存”,清空浏览器缓存(Ctrl+Shift+Del),重新访问网站前端——“没有当前站点”提示消失,统计数据正常加载! mjw78h99.png图片 四、核心修改解析:域名标准化适配 优化代码的核心是新增“域名标准化”逻辑,让不同格式的域名统一规则后再匹配,关键代码如下: $normalizeDomain = function ($domain) { $domain = preg_replace('/^https?:\/\//', '', $domain); // 去除http/https协议头 $domain = preg_replace('/^www\./', '', $domain); // 去除www前缀 return strtolower(trim($domain)); // 转为小写+去除空格 }; 原逻辑:$list[$i]['domain'] == JOE_DOMAIN(完全字符匹配); 新逻辑:$normalizeDomain($list[$i]['domain']) === $normalizeDomain(JOE_DOMAIN)(标准化后匹配)。 示例:无论原域名是www.xxx.com“https://XXX.com”还是xxx.com:80,都会被标准化为xxx.com,确保匹配成功。 此外,代码还优化了access_token刷新后的逻辑——刷新成功后自动用新token重新获取站点列表,无需手动刷新页面,流程更顺畅。 五、常见问题排查 5.1 替换后仍显示“没有当前站点” 检查JOE_DOMAIN常量是否定义:需确保主题配置中已正确设置该常量,且不为空; 核实百度统计站点:登录百度统计后台,确认当前站点已添加,且域名与标准化后的JOE_DOMAIN一致; 检查文件路径:确认修改的是“含‘没有当前站点’文本”的目标文件,而非其他同名文件。 5.2 替换后出现语法错误 确保代码完整复制:未遗漏大括号、分号等语法符号; 检查PHP版本:确保服务器PHP版本≥8.0(joe再续前缘最低要求),避免语法兼容问题。 5.3 access_token频繁失效 核实refresh_token有效性:百度统计refresh_token有效期较长,若失效需重新申请API密钥; 检查client_id/client_secret:确保与百度统计开放平台配置一致,无拼写错误。 六、总结 joe再续前缘百度统计“没有当前站点”的问题,本质是域名格式匹配的“容错性不足”。通过新增域名标准化逻辑,能快速解决不同格式域名的匹配问题,且不影响原有统计功能和API交互流程。 整个操作仅需3步:定位文件→替换代码→保存生效,无需复杂配置,新手也能轻松完成。如果在操作中遇到其他问题,欢迎在评论区留言讨论~ -
PyQt5布局管理器进阶:网格布局与表单布局(附实战代码) 第6篇:PyQt5布局管理器进阶:网格布局与表单布局(完整代码) mjw558rv.png图片 哈喽~ 欢迎来到PyQt5系列的第6篇!上一章我们通过“简易文本编辑器”实战,巩固了线性布局(QVBoxLayout/QHBoxLayout)的用法。但在实际开发中,很多复杂界面(比如计算器、用户信息表单、数据展示表格)无法只用线性布局满足——要么控件排列混乱,要么自适应效果差。今天我们就来学习两种进阶布局管理器:网格布局(QGridLayout)和表单布局(QFormLayout),彻底解决复杂界面的排版难题! 一、先明确:进阶布局的核心作用 在学习具体布局前,先搞清楚两种布局的适用场景,避免用错地方: 网格布局(QGridLayout):控件按“行×列”的网格排列,适合需要精准控制控件位置的场景(如计算器按钮、表格数据展示);支持控件跨多行/多列,灵活性极高; 表单布局(QFormLayout):专门用于“标签+输入框”的表单场景(如用户注册/登录表单、信息填写窗口),自动对齐标签和输入框,界面规整且开发高效。 核心优势:两种布局都支持自适应——窗口缩放时,控件会按预设规则自动调整大小和位置,无需手动计算坐标。 二、网格布局(QGridLayout)详解:从基础到实战 网格布局的核心逻辑是“划分网格、给控件分配行和列”,比如将界面划分为3行3列,每个控件占1个“格子”,也可以让控件占2行1列(跨行吗)、1行2列(跨列)。 1. 网格布局基础用法(完整代码) 先实现一个简单的3×3网格,放置9个按钮,演示基础的行、列分配: import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QPushButton, QGridLayout, QLabel ) from PyQt5.QtCore import Qt class GridLayoutDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QGridLayout网格布局基础演示") self.resize(400, 300) # 1. 创建网格布局实例 grid_layout = QGridLayout() # 设置控件间距(格子之间的距离) grid_layout.setSpacing(10) # 设置边距(布局与窗口边缘的距离) grid_layout.setContentsMargins(20, 20, 20, 20) # 2. 创建控件并添加到网格布局 # 核心方法:addWidget(控件, 行号, 列号, 占用行数, 占用列数) # 行号、列号从0开始;占用行数/列数默认是1(可不写) for i in range(3): # 行:0、1、2 for j in range(3): # 列:0、1、2 btn = QPushButton(f"按钮({i},{j})") # 添加到网格的(i,j)位置,占1行1列 grid_layout.addWidget(btn, i, j) # 3. 额外添加一个跨列控件(占1行2列) cross_col_btn = QPushButton("跨2列按钮") grid_layout.addWidget(cross_col_btn, 3, 0, 1, 2) # 第4行(索引3),第0列,占1行2列 # 4. 额外添加一个跨行控件(占2行1列) cross_row_btn = QPushButton("跨2行按钮") grid_layout.addWidget(cross_row_btn, 0, 3, 2, 1) # 第0行,第4列(索引3),占2行1列 # 5. 将布局绑定到窗口 self.setLayout(grid_layout) if __name__ == "__main__": app = QApplication(sys.argv) window = GridLayoutDemo() window.show() sys.exit(app.exec_())mjw55cag.png图片 2. 网格布局关键方法解析 网格布局的核心是addWidget()方法,参数含义必须记牢: addWidget(控件, row, column, rowSpan, columnSpan) # row:控件所在的行号(从0开始) # column:控件所在的列号(从0开始) # rowSpan:控件占用的行数(默认1,即不跨行) # columnSpan:控件占用的列数(默认1,即不跨列)其他常用方法: setSpacing(像素值):设置相邻控件之间的间距,避免控件挤在一起; setContentsMargins(左, 上, 右, 下):设置布局与窗口边缘的距离,提升界面美观度; setRowStretch(行号, 拉伸系数):设置某一行的拉伸权重(窗口缩放时,拉伸系数大的行占更多空间); setColumnStretch(列号, 拉伸系数):设置某一列的拉伸权重,同理。 3. 网格布局实战:简易计算器界面(核心场景) 计算器是网格布局的典型应用——按钮按固定网格排列,部分按钮(如“0”“=”)跨列。我们实现一个简易计算器的界面(含输入框+按钮区): import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QPushButton, QGridLayout, QLineEdit ) from PyQt5.QtCore import Qt class SimpleCalculator(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("简易计算器(网格布局实战)") self.resize(350, 400) # 1. 创建主布局(垂直布局:输入框在上,按钮区在下) main_layout = QGridLayout() main_layout.setSpacing(10) main_layout.setContentsMargins(15, 15, 15, 15) # 2. 创建输入框(占1行4列) self.input_edit = QLineEdit() self.input_edit.setAlignment(Qt.AlignRight) # 文本右对齐(符合计算器习惯) self.input_edit.setStyleSheet("font-size: 20px; padding: 10px;") self.input_edit.setReadOnly(True) # 输入框只读,通过按钮输入 main_layout.addWidget(self.input_edit, 0, 0, 1, 4) # 第0行,第0列,占1行4列 # 3. 定义计算器按钮文本(按网格顺序排列) btn_texts = [ '7', '8', '9', '/', '4', '5', '6', '*', '1', '2', '3', '-', '0', '.', '=', '+', 'C' # 清空按钮 ] # 4. 给按钮分配行和列,添加到网格布局 row = 1 # 从第1行开始(第0行是输入框) col = 0 for text in btn_texts: btn = QPushButton(text) btn.setStyleSheet("font-size: 16px; padding: 15px;") # 特殊处理:0按钮跨2列,C按钮跨2列 if text == '0': main_layout.addWidget(btn, row, col, 1, 2) # 占1行2列 col += 2 # 列号+2(跳过一个格子) elif text == 'C': main_layout.addWidget(btn, row, col, 1, 2) # 占1行2列 col += 2 else: main_layout.addWidget(btn, row, col) # 默认占1行1列 col += 1 # 每4列换行(计算器是4列按钮) if col >= 4: col = 0 row += 1 # 5. 绑定布局到窗口 self.setLayout(main_layout) # 6. 绑定按钮点击信号(简单演示:点击按钮显示文本到输入框) for i in range(main_layout.count()): widget = main_layout.itemAt(i).widget() if isinstance(widget, QPushButton): widget.clicked.connect(self.on_btn_click) def on_btn_click(self): """按钮点击槽函数:将按钮文本显示到输入框""" sender = self.sender() text = sender.text() if text == 'C': # 清空输入框 self.input_edit.clear() elif text == '=': # 简单计算(实际项目需处理异常,此处简化) try: result = eval(self.input_edit.text()) self.input_edit.setText(str(result)) except: self.input_edit.setText("错误") else: # 拼接文本 current_text = self.input_edit.text() self.input_edit.setText(current_text + text) if __name__ == "__main__": app = QApplication(sys.argv) calculator = SimpleCalculator() calculator.show() sys.exit(app.exec_())4. 计算器界面亮点 用网格布局精准还原计算器的按钮排列,“0”和“C”按钮跨列,符合实际计算器的交互习惯; 结合垂直布局的思路,将输入框和按钮区整合,界面层次清晰; 实现了基础的计算逻辑(数字拼接、清空、结果计算),信号与槽绑定简洁高效。 三、表单布局(QFormLayout)详解:高效实现表单界面 表单布局是“标签+输入框”的专用布局,无需手动调整对齐方式——它会自动将标签放在左侧、输入控件放在右侧,且所有标签和输入框分别对齐,开发效率极高。 1. 表单布局基础用法(完整代码) 实现一个简单的用户注册表单,包含“用户名、密码、邮箱、电话”四个字段: import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QPushButton, QFormLayout, QVBoxLayout, QComboBox ) from PyQt5.QtCore import Qt class FormLayoutDemo(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("QFormLayout表单布局基础演示") self.resize(400, 300) # 1. 创建主布局(垂直布局:表单在上,按钮在下) main_layout = QVBoxLayout() main_layout.setSpacing(20) main_layout.setContentsMargins(30, 30, 30, 30) # 2. 创建表单布局实例 form_layout = QFormLayout() form_layout.setSpacing(15) # 标签与输入框、行与行之间的间距 # 3. 创建标签和输入控件,添加到表单布局 # 核心方法:addRow(标签文本/标签控件, 输入控件) # 方式1:直接传标签文本(自动生成QLabel) form_layout.addRow("用户名:", QLineEdit()) # 方式2:传QLabel控件(可自定义标签样式) pwd_label = QLabel("密码:") pwd_label.setStyleSheet("color: #e74c3c;") pwd_edit = QLineEdit() pwd_edit.setEchoMode(QLineEdit.Password) # 密码隐藏 form_layout.addRow(pwd_label, pwd_edit) # 方式3:输入控件可以是其他类型(如下拉框) form_layout.addRow("性别:", QComboBox()) # 方式4:添加提示标签(跨两列) tip_label = QLabel("* 带星号的为必填项") tip_label.setStyleSheet("color: #95a5a6; font-size: 12px;") form_layout.addRow(tip_label) # 无输入控件时,标签跨两列 # 4. 调整标签对齐方式(默认左对齐,可改为右对齐) form_layout.setLabelAlignment(Qt.AlignRight) # 5. 创建提交按钮 submit_btn = QPushButton("提交表单") submit_btn.setStyleSheet("background-color: #3498db; color: white; padding: 8px;") # 6. 将表单布局和按钮添加到主布局 main_layout.addLayout(form_layout) main_layout.addWidget(submit_btn, alignment=Qt.AlignCenter) # 7. 绑定布局到窗口 self.setLayout(main_layout) if __name__ == "__main__": app = QApplication(sys.argv) window = FormLayoutDemo() window.show() sys.exit(app.exec_())2. 表单布局关键方法解析 addRow(标签, 输入控件):核心方法,添加一行表单;标签可以是字符串(自动生成QLabel)或QLabel实例,输入控件可以是QLineEdit、QComboBox等任意交互控件; setLabelAlignment(对齐方式):设置标签的对齐方式(如Qt.AlignRight让标签右对齐,与输入框间距更紧凑); setSpacing(像素值):设置“标签与输入框之间”和“相邻行之间”的间距; setFieldGrowthPolicy(策略):设置输入控件的拉伸策略(如QFormLayout.ExpandingFieldsGrow让输入控件随窗口缩放而拉伸)。 3. 表单布局实战:完整用户信息登记表单 整合表单布局和之前学的控件,实现一个完整的用户信息登记表单,包含输入框、下拉框、复选框,添加表单验证逻辑: import sys from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QPushButton, QFormLayout, QVBoxLayout, QComboBox, QCheckBox, QMessageBox ) from PyQt5.QtCore import Qt class UserInfoForm(QWidget): def __init__(self): super().__init__() self.init_ui() def init_ui(self): self.setWindowTitle("用户信息登记表单(表单布局实战)") self.resize(450, 350) self.setStyleSheet("font-size: 14px; color: #2c3e50;") # 1. 主布局 main_layout = QVBoxLayout() main_layout.setSpacing(20) main_layout.setContentsMargins(30, 30, 30, 30) # 2. 表单布局 form_layout = QFormLayout() form_layout.setSpacing(15) form_layout.setLabelAlignment(Qt.AlignRight) # 设置输入控件拉伸(随窗口缩放) form_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) # 3. 表单控件初始化 self.user_edit = QLineEdit() self.user_edit.setPlaceholderText("请输入用户名(3-10个字符)") self.pwd_edit = QLineEdit() self.pwd_edit.setEchoMode(QLineEdit.Password) self.pwd_edit.setPlaceholderText("请输入密码(6-16个字符)") self.email_edit = QLineEdit() self.email_edit.setPlaceholderText("请输入邮箱(如xxx@xxx.com)") self.gender_combo = QComboBox() self.gender_combo.addItems(["男", "女", "保密"]) self.phone_edit = QLineEdit() self.phone_edit.setPlaceholderText("请输入手机号(11位数字)") # 4. 添加表单行 form_layout.addRow("用户名*:", self.user_edit) form_layout.addRow("密码*:", self.pwd_edit) form_layout.addRow("邮箱*:", self.email_edit) form_layout.addRow("性别:", self.gender_combo) form_layout.addRow("手机号:", self.phone_edit) # 同意条款复选框(跨两列) self.agree_check = QCheckBox("我已阅读并同意《用户服务条款》") form_layout.addRow(self.agree_check) # 5. 提交按钮 self.submit_btn = QPushButton("提交信息") self.submit_btn.setStyleSheet(""" QPushButton { background-color: #27ae60; color: white; padding: 10px; border: none; border-radius: 4px; } QPushButton:hover { background-color: #219653; } """) # 6. 结果显示标签 self.result_label = QLabel("") self.result_label.setAlignment(Qt.AlignCenter) # 7. 添加到主布局 main_layout.addLayout(form_layout) main_layout.addWidget(self.submit_btn, alignment=Qt.AlignCenter) main_layout.addWidget(self.result_label) # 8. 绑定布局和信号 self.setLayout(main_layout) self.submit_btn.clicked.connect(self.check_form) def check_form(self): """表单验证逻辑:检查必填项、格式正确性""" # 1. 获取表单内容 username = self.user_edit.text().strip() password = self.pwd_edit.text().strip() email = self.email_edit.text().strip() gender = self.gender_combo.currentText() phone = self.phone_edit.text().strip() # 2. 验证必填项 if not username: QMessageBox.warning(self, "验证失败", "用户名不能为空!") return if not password: QMessageBox.warning(self, "验证失败", "密码不能为空!") return if not email: QMessageBox.warning(self, "验证失败", "邮箱不能为空!") return if not self.agree_check.isChecked(): QMessageBox.warning(self, "验证失败", "请同意用户服务条款!") return # 3. 验证格式(简化版) if len(username) < 3 or len(username) > 10: QMessageBox.warning(self, "验证失败", "用户名长度需在3-10个字符之间!") return if len(password) < 6 or len(password) > 16: QMessageBox.warning(self, "验证失败", "密码长度需在6-16个字符之间!") return if "@" not in email: QMessageBox.warning(self, "验证失败", "邮箱格式不正确(需包含@)!") return if phone and len(phone) != 11: QMessageBox.warning(self, "验证失败", "手机号需为11位数字!") return # 4. 验证通过,显示结果 result = f""" 信息提交成功! 用户名:{username} 性别:{gender} 邮箱:{email} 手机号:{phone if phone else "未填写"} """ self.result_label.setText(result) self.result_label.setStyleSheet("color: #27ae60;") if __name__ == "__main__": app = QApplication(sys.argv) form = UserInfoForm() form.show() sys.exit(app.exec_())5. 实战表单亮点 用表单布局快速实现规整的表单界面,标签右对齐,输入框自适应拉伸,开发效率高; 添加了完整的表单验证逻辑(必填项检查、格式验证),用QMessageBox弹出提示,提升用户体验; 结合了多种控件(输入框、下拉框、复选框),覆盖实际表单的常见场景; 美化了按钮样式(悬停效果、圆角),界面更美观。 四、布局嵌套技巧:复杂界面的核心思路 实际开发中,很少用单一布局完成复杂界面,而是通过“布局嵌套”组合使用——比如“主布局(垂直)”包含“表单布局”和“网格布局”,再搭配“水平布局”排列按钮。 嵌套核心原则: 先划分界面大区域(如“顶部标题区、中间内容区、底部按钮区”),用主布局(垂直/水平)管理; 每个小区域内部,根据控件类型选择合适的子布局(网格/表单/线性); 用addLayout()方法将子布局添加到主布局,实现层次化管理。 五、常见问题排查 问题1:网格布局控件重叠/位置错乱 → 解决:检查行号、列号是否正确,避免多个控件分配到同一个格子;跨行列时注意rowSpan和columnSpan的数值; 问题2:表单布局标签和输入框不对齐 → 解决:用setLabelAlignment()统一标签对齐方式;避免手动设置输入控件的固定宽度(让布局自动适配); 问题3:窗口缩放时控件不拉伸 → 解决:给布局添加拉伸系数(setRowStretch/setColumnStretch),或设置输入控件的拉伸策略(如表单布局的setFieldGrowthPolicy); 问题4:布局嵌套后界面混乱 → 解决:先画界面草图,明确大区域和子区域的划分;给每个布局添加setSpacing和setContentsMargins,避免间距混乱。 总结 本章我们掌握了两种进阶布局管理器的核心用法: 网格布局(QGridLayout):适合“行×列”的规整排列场景(如计算器),支持控件跨行列,灵活性极高; 表单布局(QFormLayout):专门用于“标签+输入框”的表单场景,自动对齐,开发效率高; 布局嵌套是复杂界面的核心思路,先划分大区域,再用子布局管理小区域。 下一章我们将学习PyQt5的文本与表格控件(QTextEdit与QTableWidget),进一步拓展界面的功能边界。如果在布局实操中遇到问题,或者有复杂界面的排版需求,欢迎在评论区留言讨论~ -
Python 微信自动回复工具 | 带 PyQt5 图形界面 支持 Excel 关键词配置 微信自动回复工具(带PyQt界面) 最近帮朋友处理微信客服消息,重复回复太费时间,干脆写了个带图形界面的自动回复工具。不用记命令,填个Excel路径点按钮就能跑,还能实时看日志,日常用着挺顺手。下面把完整代码和用法贴出来,有需要的可以直接拿去改。 mjr3m247.png图片 先说说要准备的东西 环境:Python 3.8+(版本太高可能和pywin32不兼容) 要装的库:直接复制下面的命令到cmd里跑 pip install pywin32 pandas pyqt5 openpyxlmjr3dd3v.png图片 Excel回复表:建个Excel文件,第一列叫“关键词”,第二列叫“回复内容”,比如这样: 关键词回复内容你好您好~有什么可以帮您?下班时间我们18点下班,急事可留言~价格具体报价请发需求给我哦~mjr3ggkk.png图片 没有的朋友们不要急,我提供了默认数据文件下载,方便测试 微信自动回复表 下载地址:https://pan.quark.cn/s/75a06eed928c 提取码: 完整代码 import sys import time import win32gui import win32api import win32con import pandas as pd from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QLabel, QLineEdit, QPushButton, QTextEdit, QVBoxLayout, QHBoxLayout, QFileDialog) from PyQt5.QtCore import QThread, pyqtSignal, Qt from PyQt5.QtGui import QFont, QPalette, QColor # 消息监听线程:单独开线程是为了不让界面卡住 class ReplyWorker(QThread): # 用来给界面发日志的信号 log_send = pyqtSignal(str) # 用来告诉界面线程是否正常启动 thread_status = pyqtSignal(bool) def __init__(self, excel_path): super().__init__() self.excel_path = excel_path self.reply_rules = {} # 存关键词和回复的对应关系 self.wechat_handle = 0 # 微信窗口的句柄 self.running = False # 控制线程运行的开关 def load_excel_rules(self): """加载Excel里的关键词和回复""" try: # 用openpyxl读xlsx格式,避免编码问题 df = pd.read_excel(self.excel_path, engine='openpyxl') # 转成字典,查起来快 self.reply_rules = dict(zip(df['关键词'], df['回复内容'])) self.log_send.emit(f"回复表加载成功!共{len(self.reply_rules)}条规则") return True except Exception as e: # 捕获各种错误:文件没找到、格式不对、列名错 err_msg = f"加载Excel失败:{str(e)}" self.log_send.emit(err_msg) return False def find_wechat(self): """找到微信窗口,返回是否找到""" # 微信PC端的窗口名一般是“微信”,类名留空不限制 self.wechat_handle = win32gui.FindWindow(None, "微信") if self.wechat_handle == 0: self.log_send.emit("没找到微信窗口!请先打开微信并登录") return False # 把微信窗口提到最前面 win32gui.SetForegroundWindow(self.wechat_handle) self.log_send.emit(f"找到微信啦!窗口句柄:{self.wechat_handle}") return True def find_child(self, parent_handle, class_name=None): """找窗口里的子控件(比如输入框、发送按钮)""" child_handles = [] # 递归遍历所有子控件 def _enum_child(hwnd, extra): if class_name is None or win32gui.GetClassName(hwnd) == class_name: extra.append(hwnd) return True win32gui.EnumChildWindows(parent_handle, _enum_child, child_handles) # 返回第一个找到的控件(一般够用) return child_handles[0] if child_handles else 0 def get_last_msg(self, chat_panel): """获取聊天面板里最后一条消息""" # 微信聊天面板的文本就是所有消息,按换行分割取最后一行 all_msg = win32gui.GetWindowText(chat_panel) if all_msg: return all_msg.split("\n")[-1].strip() return "" def send_reply(self, input_box, send_btn, reply_content): """模拟输入并发送回复""" # 先清空输入框:选中所有文本再删除 win32gui.SendMessage(input_box, win32con.EM_SETSEL, 0, -1) win32gui.SendMessage(input_box, win32con.WM_CLEAR, 0, 0) # 逐个字符输入(太快会乱码,加个小延迟) for char in reply_content: win32gui.SendMessage(input_box, win32con.WM_CHAR, ord(char), 0) time.sleep(0.02) # 点击发送按钮 win32gui.SendMessage(send_btn, win32con.BM_CLICK, 0, 0) def run(self): """线程主逻辑:启动后一直监听""" self.running = True # 先加载规则和找微信,有一个失败就退出 if not self.load_excel_rules() or not self.find_wechat(): self.thread_status.emit(False) self.running = False return self.thread_status.emit(True) # 定位微信的核心控件(不同版本可能要改class_name,用Spy++看) session_list = self.find_child(self.wechat_handle, "ContactPanel") # 会话列表 input_box = self.find_child(self.wechat_handle, "Edit") # 输入框 send_btn = self.find_child(self.wechat_handle, "Button") # 发送按钮 chat_panel = self.find_child(self.wechat_handle, "ChatPanel") # 聊天面板 # 检查控件是否都找到 if not all([session_list, input_box, send_btn, chat_panel]): self.log_send.emit("没找到微信的核心控件!可能版本不兼容") self.running = False return self.log_send.emit("所有控件已定位,开始监听消息...") # 循环监听未读消息 while self.running: # 遍历所有会话找带“未读”的 sessions = [] win32gui.EnumChildWindows(session_list, lambda hwnd, extra: extra.append(hwnd), sessions) for session in sessions: session_text = win32gui.GetWindowText(session) if "未读" in session_text: # 点击未读会话,切换到聊天界面 win32gui.SendMessage(session, win32con.BM_CLICK, 0, 0) time.sleep(0.5) # 等消息加载出来 # 提取联系人(去掉“未读”字样) contact = session_text.replace("未读", "").strip() # 提取最后一条消息 last_msg = self.get_last_msg(chat_panel) if not last_msg: continue self.log_send.emit(f"\n收到[{contact}]的消息:{last_msg}") # 匹配关键词找回复 reply = "抱歉呀,我暂时没理解你的意思~" for keyword, content in self.reply_rules.items(): if keyword in last_msg: reply = content break self.log_send.emit(f"准备回复:{reply}") # 发送回复 self.send_reply(input_box, send_btn, reply) self.log_send.emit("回复发送成功!") # 每2秒查一次,别占太多CPU time.sleep(2) def stop(self): """停止线程""" self.running = False self.log_send.emit("\n监听已停止") # 主界面窗口 class ReplyWindow(QMainWindow): def __init__(self): super().__init__() self.worker = None # 监听线程对象 self.init_ui() # 初始化界面 def init_ui(self): """画界面:布局、按钮、输入框这些""" # 窗口基本设置 self.setWindowTitle("微信自动回复工具 v1.0 - 作者:寒烟似雪 2025/12/29发布在字节曜www.ziyeyao.com博客") self.setFixedSize(1200, 1000) # 固定大小,不允许拉伸 self.setStyleSheet("background-color: #f5f5f5;") # 中心部件(主窗口必须有个中心部件才能放内容) central_widget = QWidget() self.setCentralWidget(central_widget) # 主布局(垂直排列:路径输入→按钮→日志) main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(15) # 控件之间的间距 main_layout.setContentsMargins(25, 25, 25, 25) # 边距 # 1. Excel路径选择区域(水平排列:标签→输入框→选择按钮) path_layout = QHBoxLayout() # 路径标签 path_label = QLabel("回复表路径:") path_label.setFont(QFont("微软雅黑", 10)) # 路径输入框 self.path_edit = QLineEdit() self.path_edit.setFont(QFont("微软雅黑", 9)) self.path_edit.setPlaceholderText("点击右侧按钮选择Excel文件") self.path_edit.setStyleSheet(""" QLineEdit { padding: 6px; border: 1px solid #ddd; border-radius: 4px; background-color: white; } """) # 路径选择按钮 self.select_btn = QPushButton("选择文件") self.select_btn.setFont(QFont("微软雅黑", 9)) self.select_btn.setStyleSheet(""" QPushButton { padding: 6px 12px; border: none; border-radius: 4px; background-color: #4285f4; color: white; } QPushButton:hover { background-color: #3367d6; } """) self.select_btn.clicked.connect(self.select_excel) # 绑定选择文件事件 # 把三个控件加到水平布局里 path_layout.addWidget(path_label) path_layout.addWidget(self.path_edit, stretch=1) # 输入框占满剩余空间 path_layout.addWidget(self.select_btn, stretch=0) main_layout.addLayout(path_layout) # 2. 启动/停止按钮区域(水平排列) btn_layout = QHBoxLayout() # 启动按钮 self.start_btn = QPushButton("启动监听") self.start_btn.setFont(QFont("微软雅黑", 10)) self.start_btn.setStyleSheet(""" QPushButton { padding: 8px 0; border: none; border-radius: 4px; background-color: #34a853; color: white; } QPushButton:disabled { background-color: #a0d9a0; } QPushButton:hover:enabled { background-color: #2d8d46; } """) self.start_btn.clicked.connect(self.start_listen) # 绑定启动事件 # 停止按钮 self.stop_btn = QPushButton("停止监听") self.stop_btn.setFont(QFont("微软雅黑", 10)) self.stop_btn.setStyleSheet(""" QPushButton { padding: 8px 0; border: none; border-radius: 4px; background-color: #ea4335; color: white; } QPushButton:disabled { background-color: #e8a098; } QPushButton:hover:enabled { background-color: #d33526; } """) self.stop_btn.clicked.connect(self.stop_listen) # 绑定停止事件 self.stop_btn.setEnabled(False) # 初始状态:停止按钮禁用 # 两个按钮平分宽度 btn_layout.addWidget(self.start_btn, stretch=1) btn_layout.addSpacing(10) # 按钮之间的间距 btn_layout.addWidget(self.stop_btn, stretch=1) main_layout.addLayout(btn_layout) # 3. 日志显示区域 # 日志标签 log_label = QLabel("运行日志:") log_label.setFont(QFont("微软雅黑", 10)) main_layout.addWidget(log_label) # 日志文本框(只读) self.log_edit = QTextEdit() self.log_edit.setFont(QFont("Consolas", 9)) self.log_edit.setReadOnly(True) self.log_edit.setStyleSheet(""" QTextEdit { padding: 8px; border: 1px solid #ddd; border-radius: 4px; background-color: white; color: #333; } """) main_layout.addWidget(self.log_edit, stretch=1) # 日志框占满剩余空间 def select_excel(self): """选择Excel文件,把路径填到输入框""" # 打开文件选择对话框,只显示Excel文件 file_path, _ = QFileDialog.getOpenFileName( self, "选择回复表", "", "Excel Files (*.xlsx; *.xls)" ) if file_path: self.path_edit.setText(file_path) def start_listen(self): """启动监听线程""" # 先检查路径是否填了 excel_path = self.path_edit.text().strip() if not excel_path: self.add_log("请先选择Excel回复表!") return # 检查线程是否已经在跑了 if self.worker and self.worker.isRunning(): self.add_log(" 监听已经在运行啦,不用重复启动") return # 创建线程对象,绑定信号 self.worker = ReplyWorker(excel_path) self.worker.log_send.connect(self.add_log) # 接收日志信号 self.worker.thread_status.connect(self.set_btn_status) # 接收线程状态信号 self.worker.finished.connect(self.thread_finished) # 线程结束时的信号 # 启动线程 self.worker.start() # 暂时禁用启动按钮 self.start_btn.setEnabled(False) self.add_log("正在初始化监听...") def stop_listen(self): """停止监听线程""" if self.worker and self.worker.isRunning(): self.worker.stop() # 禁用停止按钮,启用启动按钮 self.stop_btn.setEnabled(False) self.start_btn.setEnabled(True) else: self.add_log(" 监听还没启动呢,不用停止") def add_log(self, msg): """往日志框里加内容,自动滚到最下面""" # 加个时间戳,方便看什么时候发生的 time_str = time.strftime("[%H:%M:%S]", time.localtime()) self.log_edit.append(f"{time_str} {msg}") # 自动滚动到最后一行 self.log_edit.moveCursor(self.log_edit.textCursor().End) def set_btn_status(self, is_running): """根据线程状态设置按钮是否可用""" self.start_btn.setEnabled(not is_running) self.stop_btn.setEnabled(is_running) def thread_finished(self): """线程结束时的处理""" self.set_btn_status(False) self.add_log("监听线程已结束") def closeEvent(self, event): """窗口关闭时,确保线程也停了""" if self.worker and self.worker.isRunning(): self.worker.stop() self.worker.wait() # 等线程彻底结束 event.accept() def add_log(self, msg): """往日志里加内容(单独写个方法,方便调用)""" time_str = time.strftime("[%H:%M:%S]", time.localtime()) self.log_edit.append(f"{time_str} {msg}") self.log_edit.ensureCursorVisible() # 自动滚屏 # 程序入口 if __name__ == "__main__": app = QApplication(sys.argv) window = ReplyWindow() window.show() sys.exit(app.exec_()) 怎么用? 先建好转发的Excel表(列名必须是“关键词”和“回复内容”) 运行代码,会弹出一个窗口 点击“选择文件”,找到你建的Excel表 点击“启动监听”,然后打开微信(必须是PC端,登录状态) 有人发消息含关键词,就会自动回复了,日志里能看到过程 不想用了就点“停止监听”,或者直接关窗口 mjr3et38.png图片 注意事项 微信版本兼容问题 这个工具是靠pywin32识别微信窗口控件来实现的,不同版本的微信,控件的类名(比如ContactPanel、ChatPanel)可能不一样。如果运行时提示“没找到核心控件”,可以用Spy++(VS自带工具)查看你电脑上微信的控件类名,然后修改代码里find_child方法传入的class_name参数。 必须打开微信PC端 工具没法模拟微信登录,运行前一定要手动打开微信PC端并登录账号,而且最好不要最小化微信窗口,否则可能识别不到控件。 Excel格式要求 Excel文件里必须有两列,列名严格对应“关键词”和“回复内容”,建议保存为.xlsx格式,避免编码问题。如果提示“加载Excel失败”,检查一下文件路径有没有中文或特殊字符,或者是不是被其他软件占用了。 避免重复回复 工具默认每2秒扫描一次未读消息,如果你担心同一条消息被重复回复,可以在代码里加一个“已处理消息列表”,把已经回复过的消息内容存进去,下次扫描时先判断是否在列表里。 权限问题 运行程序时,如果遇到“权限不足”的提示,右键点击Python.exe,选择“以管理员身份运行”,或者给当前用户授予窗口控制的权限。 常见问题解决 问题1:启动后提示“没找到微信窗口” 解决:确认微信PC端已经打开,并且窗口标题是“微信”(不是其他自定义标题);如果微信在任务栏隐藏,先点击显示出来。 问题2:能找到微信,但提示“没找到核心控件” 解决:用Spy++查看微信的会话列表、输入框、发送按钮的类名,替换代码里对应的class_name;比如有些版本的微信输入框类名是RichEdit20W。 问题3:回复内容发送乱码 解决:在send_reply方法里,延长字符输入的延迟时间,把time.sleep(0.02)改成time.sleep(0.05);同时确保Excel文件的编码是UTF-8。 问题4:线程启动后,界面卡死 解决:检查是不是把监听逻辑写在了主线程里——这个工具的监听代码在ReplyWorker线程里,和界面线程分离,不会卡界面;如果还是卡,大概率是控件识别耗时太长,可以减少扫描频率,把time.sleep(2)改成time.sleep(5)。 结语 这便是我制作的微信自动回复工具啦,有bug可以在评论区留言。有动手能力的朋友们可以尝试添加以下功能: 添加黑白名单:可以在Excel里加一列“是否启用”,或者单独建一个黑白名单文件,指定哪些联系人需要自动回复,哪些不需要。 支持多关键词匹配:现在是匹配到第一个关键词就回复,可以改成支持多个关键词同时匹配,比如“价格”和“优惠”同时出现时,回复特定内容。 定时启停:添加一个时间选择控件,设置每天的监听时间段,比如只在9:00-18:00运行,其他时间自动停止。 回复记录导出:把收到的消息和发送的回复记录到本地文件(比如CSV),方便后续查看和统计。