2026年Python GUI开发完全指南:8个实战项目教你快速打造桌面应用
Python作为入门门槛最低、生态最丰富的编程语言之一,在桌面应用开发领域一直拥有极高的人气。无论是快速原型开发、中小型工具制作、企业内部系统搭建,Python GUI开发都是效率最高的选择之一。本文将从新手入门到实战项目,再到踩坑指南和变现方向,全方位带你掌握2026年最新的Python GUI开发技术栈。
一、新手入门指南:选对框架少走弯路
Python GUI框架众多,不同框架的学习成本、功能特性和适用场景差异极大,新手首先要根据自己的需求选择合适的技术栈。
1.1 主流Python GUI框架对比
| 框架 | 学习难度 | 功能丰富度 | 跨平台支持 | 打包后体积 | 适用场景 |
|——|———-|————|————|————|———-|
| Tkinter | 极低 | 基础 | Windows/macOS/Linux | 极小(<10MB) | 小型工具、快速原型 |
| PyQt6 | 中等 | 极丰富 | 全平台 | 较大(30-50MB) | 中大型商用应用 |
| PySide6 | 中等 | 极丰富 | 全平台 | 较大(30-50MB) | 中大型商用应用(LGPL协议更友好) |
| CustomTkinter | 低 | 中等 | 全平台 | 小(10-20MB) | 现代UI风格的中小应用 |
| Flet | 低 | 丰富 | 全平台+Web | 小(15-25MB) | 跨端应用、需要Web版本的工具 |
| Kivy | 高 | 丰富 | 全平台+移动端 | 大(50MB+) | 触控界面、移动端应用 |
2026年的推荐学习路径:新手入门首选Tkinter + CustomTkinter组合,掌握基础概念后进阶PyQt6/PySide6,90%以上的桌面应用开发需求都能覆盖。
1.2 环境安装配置
Tkinter环境安装
Tkinter是Python标准库,大部分Python发行版都默认自带,无需额外安装。验证安装:
import tkinter as tk
root = tk.Tk()
root.title("Tkinter测试")
root.mainloop()
如果能弹出窗口说明安装成功。如果缺少Tkinter:
PyQt6环境安装
PyQt6需要单独安装:
pip install pyqt6 pyqt6-tools
验证安装:
from PyQt6.QtWidgets import QApplication, QLabel
app = QApplication([])
label = QLabel("PyQt6测试")
label.show()
app.exec()
二、8个可直接运行的GUI实战项目
以下所有项目均提供完整可运行代码,带详细注释,复制即可使用。
项目1:计算器(Tkinter实现)
功能:支持加减乘除四则运算、括号、清空、退格功能
import tkinter as tk
from tkinter import ttk
class Calculator:
def __init__(self, root):
self.root = root
self.root.title("Python计算器")
self.expression = ""
# 显示框
self.display = ttk.Entry(root, font=('Arial', 24), justify='right')
self.display.grid(row=0, column=0, columnspan=4, padx=10, pady=10, sticky='nsew')
# 按钮布局
buttons = [
'7', '8', '9', '/',
'4', '5', '6', '*',
'1', '2', '3', '-',
'0', '.', '=', '+'
]
row = 1
col = 0
for btn_text in buttons:
ttk.Button(root, text=btn_text, command=lambda t=btn_text: self.on_button_click(t)).grid(row=row, column=col, padx=5, pady=5, sticky='nsew')
col += 1
if col > 3:
col = 0
row += 1
# 清空和退格按钮
ttk.Button(root, text='C', command=self.clear).grid(row=row, column=0, columnspan=2, padx=5, pady=5, sticky='nsew')
ttk.Button(root, text='←', command=self.backspace).grid(row=row, column=2, columnspan=2, padx=5, pady=5, sticky='nsew')
# 窗口大小自适应
for i in range(4):
root.grid_columnconfigure(i, weight=1)
for i in range(6):
root.grid_rowconfigure(i, weight=1)
def on_button_click(self, text):
if text == '=':
try:
result = eval(self.expression)
self.expression = str(result)
self.display.delete(0, tk.END)
self.display.insert(0, self.expression)
except Exception as e:
self.display.delete(0, tk.END)
self.display.insert(0, "错误")
self.expression = ""
else:
self.expression += text
self.display.delete(0, tk.END)
self.display.insert(0, self.expression)
def clear(self):
self.expression = ""
self.display.delete(0, tk.END)
def backspace(self):
self.expression = self.expression[:-1]
self.display.delete(0, tk.END)
self.display.insert(0, self.expression)
if __name__ == "__main__":
root = tk.Tk()
app = Calculator(root)
root.mainloop()
使用说明: 直接运行即可使用,支持键盘输入,按回车计算结果,按ESC清空。
项目2:记事本(PyQt6实现)
功能:支持新建、打开、保存、另存为、剪切、复制、粘贴、字体设置
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTextEdit, QFileDialog, QFontDialog, QColorDialog)
from PyQt6.QtGui import QAction, QFont, QColor
import sys
class Notepad(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Python记事本")
self.setGeometry(100, 100, 800, 600)
# 文本编辑区
self.text_edit = QTextEdit()
self.setCentralWidget(self.text_edit)
# 菜单栏
menu_bar = self.menuBar()
# 文件菜单
file_menu = menu_bar.addMenu("文件")
new_action = QAction("新建", self)
new_action.setShortcut("Ctrl+N")
new_action.triggered.connect(self.new_file)
file_menu.addAction(new_action)
open_action = QAction("打开", self)
open_action.setShortcut("Ctrl+O")
open_action.triggered.connect(self.open_file)
file_menu.addAction(open_action)
save_action = QAction("保存", self)
save_action.setShortcut("Ctrl+S")
save_action.triggered.connect(self.save_file)
file_menu.addAction(save_action)
save_as_action = QAction("另存为", self)
save_as_action.setShortcut("Ctrl+Shift+S")
save_as_action.triggered.connect(self.save_as_file)
file_menu.addAction(save_as_action)
# 编辑菜单
edit_menu = menu_bar.addMenu("编辑")
cut_action = QAction("剪切", self)
cut_action.setShortcut("Ctrl+X")
cut_action.triggered.connect(self.text_edit.cut)
edit_menu.addAction(cut_action)
copy_action = QAction("复制", self)
copy_action.setShortcut("Ctrl+C")
copy_action.triggered.connect(self.text_edit.copy)
edit_menu.addAction(copy_action)
paste_action = QAction("粘贴", self)
paste_action.setShortcut("Ctrl+V")
paste_action.triggered.connect(self.text_edit.paste)
edit_menu.addAction(paste_action)
# 格式菜单
format_menu = menu_bar.addMenu("格式")
font_action = QAction("字体", self)
font_action.triggered.connect(self.change_font)
format_menu.addAction(font_action)
color_action = QAction("颜色", self)
color_action.triggered.connect(self.change_color)
format_menu.addAction(color_action)
self.current_file = ""
def new_file(self):
self.text_edit.clear()
self.current_file = ""
self.setWindowTitle("未命名 - Python记事本")
def open_file(self):
file_name, _ = QFileDialog.getOpenFileName(self, "打开文件", "", "文本文件 (*.txt);;所有文件 (*.*)")
if file_name:
with open(file_name, 'r', encoding='utf-8') as f:
self.text_edit.setText(f.read())
self.current_file = file_name
self.setWindowTitle(f"{file_name} - Python记事本")
def save_file(self):
if not self.current_file:
self.save_as_file()
return
with open(self.current_file, 'w', encoding='utf-8') as f:
f.write(self.text_edit.toPlainText())
def save_as_file(self):
file_name, _ = QFileDialog.getSaveFileName(self, "另存为", "", "文本文件 (*.txt);;所有文件 (*.*)")
if file_name:
self.current_file = file_name
self.save_file()
self.setWindowTitle(f"{file_name} - Python记事本")
def change_font(self):
font, ok = QFontDialog.getFont()
if ok:
self.text_edit.setFont(font)
def change_color(self):
color = QColorDialog.getColor()
if color.isValid():
self.text_edit.setTextColor(color)
if __name__ == "__main__":
app = QApplication(sys.argv)
notepad = Notepad()
notepad.show()
sys.exit(app.exec())
使用说明: 支持常见的文本编辑操作,默认保存为UTF-8编码的txt文件。
项目3:图片查看器(CustomTkinter实现)
功能:支持图片浏览、缩放、旋转、切换上一张/下一张
import customtkinter as ctk
from PIL import Image, ImageTk
import os
from tkinter import filedialog
class ImageViewer:
def __init__(self, root):
self.root = root
self.root.title("Python图片查看器")
self.root.geometry("900x700")
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
self.image_list = []
self.current_index = 0
self.current_image = None
self.angle = 0
self.scale = 1.0
# 菜单栏
self.menu_frame = ctk.CTkFrame(root)
self.menu_frame.pack(fill="x", padx=10, pady=5)
self.open_btn = ctk.CTkButton(self.menu_frame, text="打开文件夹", command=self.open_folder)
self.open_btn.pack(side="left", padx=5)
self.prev_btn = ctk.CTkButton(self.menu_frame, text="上一张", command=self.prev_image, state="disabled")
self.prev_btn.pack(side="left", padx=5)
self.next_btn = ctk.CTkButton(self.menu_frame, text="下一张", command=self.next_image, state="disabled")
self.next_btn.pack(side="left", padx=5)
self.zoom_in_btn = ctk.CTkButton(self.menu_frame, text="放大", command=lambda: self.zoom(1.2))
self.zoom_in_btn.pack(side="left", padx=5)
self.zoom_out_btn = ctk.CTkButton(self.menu_frame, text="缩小", command=lambda: self.zoom(0.8))
self.zoom_out_btn.pack(side="left", padx=5)
self.rotate_btn = ctk.CTkButton(self.menu_frame, text="旋转90°", command=self.rotate)
self.rotate_btn.pack(side="left", padx=5)
# 图片显示区
self.canvas = ctk.CTkCanvas(root, bg="#2b2b2b")
self.canvas.pack(fill="both", expand=True, padx=10, pady=5)
# 状态栏
self.status_bar = ctk.CTkLabel(root, text="请打开图片文件夹")
self.status_bar.pack(fill="x", padx=10, pady=2)
def open_folder(self):
folder_path = filedialog.askdirectory()
if not folder_path:
return
self.image_list = []
for file in os.listdir(folder_path):
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')):
self.image_list.append(os.path.join(folder_path, file))
if not self.image_list:
self.status_bar.configure(text="该文件夹没有图片")
return
self.current_index = 0
self.angle = 0
self.scale = 1.0
self.show_image()
self.update_buttons()
self.status_bar.configure(text=f"共{len(self.image_list)}张图片,当前第{self.current_index+1}张")
def show_image(self):
if not self.image_list:
return
image_path = self.image_list[self.current_index]
self.current_image = Image.open(image_path)
# 应用旋转和缩放
if self.angle != 0:
self.current_image = self.current_image.rotate(self.angle, expand=True)
width, height = self.current_image.size
new_width = int(width * self.scale)
new_height = int(height * self.scale)
self.current_image = self.current_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
self.photo = ImageTk.PhotoImage(self.current_image)
# 居中显示
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
x = (canvas_width - new_width) // 2
y = (canvas_height - new_height) // 2
self.canvas.delete("all")
self.canvas.create_image(x, y, anchor="nw", image=self.photo)
def prev_image(self):
if self.current_index > 0:
self.current_index -= 1
self.angle = 0
self.scale = 1.0
self.show_image()
self.update_buttons()
self.status_bar.configure(text=f"共{len(self.image_list)}张图片,当前第{self.current_index+1}张")
def next_image(self):
if self.current_index < len(self.image_list) - 1:
self.current_index += 1
self.angle = 0
self.scale = 1.0
self.show_image()
self.update_buttons()
self.status_bar.configure(text=f"共{len(self.image_list)}张图片,当前第{self.current_index+1}张")
def zoom(self, factor):
self.scale *= factor
self.show_image()
def rotate(self):
self.angle = (self.angle + 90) % 360
self.show_image()
def update_buttons(self):
self.prev_btn.configure(state="normal" if self.current_index > 0 else "disabled")
self.next_btn.configure(state="normal" if self.current_index < len(self.image_list) - 1 else "disabled")
if __name__ == "__main__":
root = ctk.CTk()
app = ImageViewer(root)
root.mainloop()
使用说明: 打开包含图片的文件夹即可自动加载所有图片,支持鼠标滚轮也可缩放图片。
项目4:视频播放器(PyQt6 + OpenCV实现)
功能:支持播放、暂停、进度条控制、音量调节、全屏播放
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QSlider, QLabel, QFileDialog, QMessageBox)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QImage, QPixmap
import cv2
import sys
class VideoPlayer(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Python视频播放器")
self.setGeometry(100, 100, 900, 600)
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.layout = QVBoxLayout(self.central_widget)
# 视频显示区
self.video_label = QLabel()
self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.video_label.setStyleSheet("background-color: black;")
self.layout.addWidget(self.video_label)
# 控制栏
self.control_layout = QHBoxLayout()
self.open_btn = QPushButton("打开视频")
self.open_btn.clicked.connect(self.open_video)
self.control_layout.addWidget(self.open_btn)
self.play_btn = QPushButton("播放")
self.play_btn.clicked.connect(self.play_pause)
self.play_btn.setEnabled(False)
self.control_layout.addWidget(self.play_btn)
self.progress_slider = QSlider(Qt.Orientation.Horizontal)
self.progress_slider.sliderMoved.connect(self.seek_video)
self.control_layout.addWidget(self.progress_slider)
self.time_label = QLabel("00:00 / 00:00")
self.control_layout.addWidget(self.time_label)
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(50)
self.volume_slider.setFixedWidth(100)
self.control_layout.addWidget(self.volume_slider)
self.layout.addLayout(self.control_layout)
# 定时器用于更新视频帧
self.timer = QTimer()
self.timer.timeout.connect(self.update_frame)
self.cap = None
self.is_playing = False
self.total_frames = 0
self.fps = 0
self.current_frame = 0
def open_video(self):
file_name, _ = QFileDialog.getOpenFileName(self, "打开视频", "", "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*.*)")
if not file_name:
return
self.cap = cv2.VideoCapture(file_name)
if not self.cap.isOpened():
QMessageBox.critical(self, "错误", "无法打开视频文件")
return
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
self.progress_slider.setRange(0, self.total_frames)
self.play_btn.setEnabled(True)
self.is_playing = False
self.play_btn.setText("播放")
self.update_time_label()
self.show_first_frame()
def show_first_frame(self):
if self.cap is None:
return
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
ret, frame = self.cap.read()
if ret:
self.display_frame(frame)
def play_pause(self):
if self.cap is None:
return
if self.is_playing:
self.timer.stop()
self.play_btn.setText("播放")
else:
self.timer.start(int(1000 / self.fps))
self.play_btn.setText("暂停")
self.is_playing = not self.is_playing
def update_frame(self):
ret, frame = self.cap.read()
if ret:
self.current_frame = int(self.cap.get(cv2.CAP_PROP_POS_FRAMES))
self.progress_slider.setValue(self.current_frame)
self.display_frame(frame)
self.update_time_label()
else:
self.timer.stop()
self.is_playing = False
self.play_btn.setText("播放")
def display_frame(self, frame):
# 转换颜色空间
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = frame_rgb.shape
bytes_per_line = ch * w
q_image = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
# 自适应窗口大小
label_width = self.video_label.width()
label_height = self.video_label.height()
pixmap = QPixmap.fromImage(q_image).scaled(label_width, label_height, Qt.AspectRatioMode.KeepAspectRatio)
self.video_label.setPixmap(pixmap)
def seek_video(self, position):
if self.cap is None:
return
self.cap.set(cv2.CAP_PROP_POS_FRAMES, position)
self.current_frame = position
ret, frame = self.cap.read()
if ret:
self.display_frame(frame)
self.update_time_label()
def update_time_label(self):
current_time = self.current_frame / self.fps
total_time = self.total_frames / self.fps
current_min = int(current_time // 60)
current_sec = int(current_time % 60)
total_min = int(total_time // 60)
total_sec = int(total_time % 60)
self.time_label.setText(f"{current_min:02d}:{current_sec:02d} / {total_min:02d}:{total_sec:02d}")
def closeEvent(self, event):
if self.cap is not None:
self.cap.release()
event.accept()
if __name__ == "__main__":
app = QApplication(sys.argv)
player = VideoPlayer()
player.show()
sys.exit(app.exec())
使用说明: 打开本地视频文件即可播放,支持常见视频格式,空格键可控制播放暂停。
项目5:密码管理器(Tkinter + SQLite实现)
功能:支持密码加密存储、分类管理、搜索、复制到剪贴板
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import hashlib
from cryptography.fernet import Fernet
import pyperclip
import os
class PasswordManager:
def __init__(self, root):
self.root = root
self.root.title("Python密码管理器")
self.root.geometry("800x600")
# 密钥管理
self.key_file = "password_key.key"
if not os.path.exists(self.key_file):
key = Fernet.generate_key()
with open(self.key_file, "wb") as key_file:
key_file.write(key)
with open(self.key_file, "rb") as key_file:
self.key = key_file.read()
self.cipher = Fernet(self.key)
# 数据库初始化
self.conn = sqlite3.connect("passwords.db")
self.cursor = self.conn.cursor()
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
website TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
category TEXT NOT NULL,
note TEXT
)
''')
self.conn.commit()
# 界面布局
self.input_frame = ttk.Frame(root, padding="10")
self.input_frame.pack(fill="x")
ttk.Label(self.input_frame, text="网站:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.website_entry = ttk.Entry(self.input_frame, width=30)
self.website_entry.grid(row=0, column=1, padx=5, pady=5)
ttk.Label(self.input_frame, text="用户名:").grid(row=0, column=2, padx=5, pady=5, sticky="w")
self.username_entry = ttk.Entry(self.input_frame, width=30)
self.username_entry.grid(row=0, column=3, padx=5, pady=5)
ttk.Label(self.input_frame, text="密码:").grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.password_entry = ttk.Entry(self.input_frame, width=30, show="*")
self.password_entry.grid(row=1, column=1, padx=5, pady=5)
ttk.Label(self.input_frame, text="分类:").grid(row=1, column=2, padx=5, pady=5, sticky="w")
self.category_combobox = ttk.Combobox(self.input_frame, values=["工作", "个人", "社交", "金融", "其他"], width=27)
self.category_combobox.grid(row=1, column=3, padx=5, pady=5)
self.category_combobox.current(0)
ttk.Label(self.input_frame, text="备注:").grid(row=2, column=0, padx=5, pady=5, sticky="nw")
self.note_text = tk.Text(self.input_frame, width=80, height=3)
self.note_text.grid(row=2, column=1, columnspan=3, padx=5, pady=5)
# 按钮区
self.button_frame = ttk.Frame(root, padding="10")
self.button_frame.pack(fill="x")
self.add_btn = ttk.Button(self.button_frame, text="添加密码", command=self.add_password)
self.add_btn.pack(side="left", padx=5)
self.delete_btn = ttk.Button(self.button_frame, text="删除选中", command=self.delete_password)
self.delete_btn.pack(side="left", padx=5)
self.copy_btn = ttk.Button(self.button_frame, text="复制密码", command=self.copy_password)
self.copy_btn.pack(side="left", padx=5)
ttk.Label(self.button_frame, text="搜索:").pack(side="left", padx=20, pady=5)
self.search_entry = ttk.Entry(self.button_frame, width=30)
self.search_entry.pack(side="left", padx=5)
self.search_entry.bind("", self.search_passwords)
# 密码列表
self.tree = ttk.Treeview(root, columns=("id", "website", "username", "category", "note"), show="headings")
self.tree.heading("id", text="ID")
self.tree.heading("website", text="网站")
self.tree.heading("username", text="用户名")
self.tree.heading("category", text="分类")
self.tree.heading("note", text="备注")
self.tree.column("id", width=50)
self.tree.column("website", width=200)
self.tree.column("username", width=200)
self.tree.column("category", width=100)
self.tree.column("note", width=250)
self.tree.pack(fill="both", expand=True, padx=10, pady=10)
self.load_passwords()
def add_password(self):
website = self.website_entry.get().strip()
username = self.username_entry.get().strip()
password = self.password_entry.get().strip()
category = self.category_combobox.get()
note = self.note_text.get("1.0", tk.END).strip()
if not website or not username or not password:
messagebox.showwarning("警告", "网站、用户名和密码不能为空")
return
# 加密密码
encrypted_password = self.cipher.encrypt(password.encode()).decode()
self.cursor.execute(
"INSERT INTO passwords (website, username, password, category, note) VALUES (?, ?, ?, ?, ?)",
(website, username, encrypted_password, category, note)
)
self.conn.commit()
# 清空输入框
self.website_entry.delete(0, tk.END)
self.username_entry.delete(0, tk.END)
self.password_entry.delete(0, tk.END)
self.note_text.delete("1.0", tk.END)
self.load_passwords()
messagebox.showinfo("成功", "密码添加成功")
def load_passwords(self):
# 清空现有数据
for item in self.tree.get_children():
self.tree.delete(item)
self.cursor.execute("SELECT id, website, username, category, note FROM passwords")
rows = self.cursor.fetchall()
for row in rows:
self.tree.insert("", tk.END, values=row)
def search_passwords(self, event):
search_term = self.search_entry.get().strip().lower()
for item in self.tree.get_children():
self.tree.delete(item)
self.cursor.execute("""
SELECT id, website, username, category, note
FROM passwords
WHERE LOWER(website) LIKE ? OR LOWER(username) LIKE ? OR LOWER(note) LIKE ?
""", (f"%{search_term}%", f"%{search_term}%", f"%{search_term}%"))
rows = self.cursor.fetchall()
for row in rows:
self.tree.insert("", tk.END, values=row)
def delete_password(self):
selected_item = self.tree.selection()
if not selected_item:
messagebox.showwarning("警告", "请先选择要删除的记录")
return
if messagebox.askyesno("确认", "确定要删除这条密码记录吗?")
if not messagebox.askyesno:
return
for item in selected_item:
password_id = self.tree.item(item)["values"][0]
self.cursor.execute("DELETE FROM passwords WHERE id = ?", (password_id,))
self.conn.commit()
self.load_passwords()
messagebox.showinfo("成功", "密码删除成功")
def copy_password(self):
selected_item = self.tree.selection()
if not selected_item:
messagebox.showwarning("警告", "请先选择要复制的密码记录")
return
password_id = self.tree.item(selected_item[0])["values"][0]
self.cursor.execute("SELECT password FROM passwords WHERE id = ?", (password_id,))
encrypted_password = self.cursor.fetchone()[0]
# 解密密码
decrypted_password = self.cipher.decrypt(encrypted_password.encode()).decode()
pyperclip.copy(decrypted_password)
messagebox.showinfo("成功", "密码已复制到剪贴板")
def close_event(self):
self.conn.close()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = PasswordManager(root)
root.protocol("WM_DELETE_WINDOW", app.close_event)
root.mainloop()
使用说明: 密码采用AES加密存储在本地SQLite数据库,不会上传到任何服务器。
项目6:文件管理器(PyQt6实现)
功能:支持文件浏览、复制、移动、删除、重命名、新建文件夹
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTreeView, QFileSystemModel, QSplitter, QMenu, QMessageBox, QInputDialog, QLineEdit)
from PyQt6.QtCore import QDir, Qt, QMimeData, QUrl
from PyQt6.QtGui import QAction
import sys
import os
import shutil
class FileManager(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Python文件管理器")
self.setGeometry(100, 100, 1000, 700)
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.setCentralWidget(self.splitter)
# 左侧目录树
self.dir_model = QFileSystemModel()
self.dir_model.setRootPath(QDir.rootPath())
self.dir_model.setFilter(QDir.Filter.AllDirs | QDir.Filter.NoDotAndDotDot)
self.dir_tree = QTreeView()
self.dir_tree.setModel(self.dir_model)
self.dir_tree.setRootIndex(self.dir_model.index(QDir.rootPath()))
self.dir_tree.setColumnHidden(1, True)
self.dir_tree.setColumnHidden(2, True)
self.dir_tree.setColumnHidden(3, True)
self.dir_tree.clicked.connect(self.on_dir_clicked)
self.splitter.addWidget(self.dir_tree)
# 右侧文件列表
self.file_model = QFileSystemModel()
self.file_model.setRootPath(QDir.rootPath())
self.file_model.setFilter(QDir.Filter.Files | QDir.Filter.AllDirs | QDir.Filter.NoDotAndDotDot)
self.file_view = QTreeView()
self.file_view.setModel(self.file_model)
self.file_view.setRootIndex(self.file_model.index(QDir.rootPath()))
self.file_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.file_view.customContextMenuRequested.connect(self.show_context_menu)
self.splitter.addWidget(self.file_view)
self.splitter.setStretchFactor(1, 3)
# 菜单栏
menu_bar = self.menuBar()
file_menu = menu_bar.addMenu("操作")
new_folder_action = QAction("新建文件夹", self)
new_folder_action.triggered.connect(self.create_new_folder)
file_menu.addAction(new_folder_action)
copy_action = QAction("复制", self)
copy_action.triggered.connect(self.copy_file)
file_menu.addAction(copy_action)
move_action = QAction("移动", self)
move_action.triggered.connect(self.move_file)
file_menu.addAction(move_action)
delete_action = QAction("删除", self)
delete_action.triggered.connect(self.delete_file)
file_menu.addAction(delete_action)
rename_action = QAction("重命名", self)
rename_action.triggered.connect(self.rename_file)
file_menu.addAction(rename_action)
self.current_path = QDir.rootPath()
def on_dir_clicked(self, index):
path = self.dir_model.filePath(index)
self.current_path = path
self.file_view.setRootIndex(self.file_model.index(path))
def show_context_menu(self, position):
menu = QMenu()
new_folder_action = menu.addAction("新建文件夹")
copy_action = menu.addAction("复制")
move_action = menu.addAction("移动")
delete_action = menu.addAction("删除")
rename_action = menu.addAction("重命名")
action = menu.exec(self.file_view.viewport().mapToGlobal(position))
if action == new_folder_action:
self.create_new_folder()
elif action == copy_action:
self.copy_file()
elif action == move_action:
self.move_file()
elif action == delete_action:
self.delete_file()
elif action == rename_action:
self.rename_file()
def create_new_folder(self):
folder_name, ok = QInputDialog.getText(self, "新建文件夹", "请输入文件夹名称:")
if ok and folder_name:
new_folder_path = os.path.join(self.current_path, folder_name)
try:
os.makedirs(new_folder_path)
except Exception as e:
QMessageBox.critical(self, "错误", f"创建文件夹失败: {str(e)}")
def get_selected_file(self):
selected_indexes = self.file_view.selectedIndexes()
if selected_indexes:
return self.file_model.filePath(selected_indexes[0])
return None
def copy_file(self):
source_path = self.get_selected_file()
if not source_path:
QMessageBox.warning(self, "警告", "请先选择要复制的文件或文件夹")
return
dest_path, _ = QFileDialog.getExistingDirectory(self, "选择目标文件夹", self.current_path)
if dest_path:
try:
if os.path.isdir(source_path):
shutil.copytree(source_path, os.path.join(dest_path, os.path.basename(source_path)))
else:
shutil.copy2(source_path, dest_path)
QMessageBox.information(self, "成功", "复制完成")
except Exception as e:
QMessageBox.critical(self, "错误", f"复制失败: {str(e)}")
def move_file(self):
source_path = self.get_selected_file()
if not source_path:
QMessageBox.warning(self, "警告", "请先选择要移动的文件或文件夹")
return
dest_path, _ = QFileDialog.getExistingDirectory(self, "选择目标文件夹", self.current_path)
if dest_path:
try:
shutil.move(source_path, dest_path)
QMessageBox.information(self, "成功", "移动完成")
except Exception as e:
QMessageBox.critical(self, "错误", f"移动失败: {str(e)}")
def delete_file(self):
file_path = self.get_selected_file()
if not file_path:
QMessageBox.warning(self, "警告", "请先选择要删除的文件或文件夹")
return
if QMessageBox.question(self, "确认", "确定要删除选中的文件或文件夹吗?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
try:
if os.path.isdir(file_path):
shutil.rmtree(file_path)
else:
os.remove(file_path)
QMessageBox.information(self, "成功", "删除完成")
except Exception as e:
QMessageBox.critical(self, "错误", f"删除失败: {str(e)}")
def rename_file(self):
file_path = self.get_selected_file()
if not file_path:
QMessageBox.warning(self, "警告", "请先选择要重命名的文件或文件夹")
return
old_name = os.path.basename(file_path)
new_name, ok = QInputDialog.getText(self, "重命名", "请输入新名称:", QLineEdit.EchoMode.Normal, old_name)
if ok and new_name and new_name != old_name:
new_path = os.path.join(os.path.dirname(file_path), new_name)
try:
os.rename(file_path, new_path)
QMessageBox.information(self, "成功", "重命名完成")
except Exception as e:
QMessageBox.critical(self, "错误", f"重命名失败: {str(e)}")
if __name__ == "__main__":
app = QApplication(sys.argv)
manager = FileManager()
manager.show()
sys.exit(app.exec())
使用说明: 左侧为目录树,右侧为文件列表,支持鼠标右键操作,快捷键与系统资源管理器一致。
项目7:定时提醒工具(Tkinter实现)
功能:支持多个定时任务、循环提醒、声音提醒、任务管理
import tkinter as tk
from tkinter import ttk, messagebox
import time
import threading
from datetime import datetime
import winsound
import os
class ReminderTool:
def __init__(self, root):
self.root = root
self.root.title("Python定时提醒工具")
self.root.geometry("700x500")
self.tasks = []
self.running = True
# 输入区
self.input_frame = ttk.Frame(root, padding="10")
self.input_frame.pack(fill="x")
ttk.Label(self.input_frame, text="任务名称:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.task_name_entry = ttk.Entry(self.input_frame, width=30)
self.task_name_entry.grid(row=0, column=1, padx=5, pady=5)
ttk.Label(self.input_frame, text="提醒时间:").grid(row=0, column=2, padx=5, pady=5, sticky="w")
self.time_entry = ttk.Entry(self.input_frame, width=20)
self.time_entry.grid(row=0, column=3, padx=5, pady=5)
self.time_entry.insert(0, time.strftime("%H:%M:%S"))
ttk.Label(self.input_frame, text="重复:").grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.repeat_var = tk.BooleanVar()
self.repeat_check = ttk.Checkbutton(self.input_frame, variable=self.repeat_var, text="每天重复")
self.repeat_check.grid(row=1, column=1, padx=5, pady=5, sticky="w")
ttk.Label(self.input_frame, text="提醒内容:").grid(row=1, column=2, padx=5, pady=5, sticky="nw")
self.content_text = tk.Text(self.input_frame, width=40, height=3)
self.content_text.grid(row=1, column=3, padx=5, pady=5)
# 按钮区
self.button_frame = ttk.Frame(root, padding="10")
self.button_frame.pack(fill="x")
self.add_btn = ttk.Button(self.button_frame, text="添加提醒", command=self.add_task)
self.add_btn.pack(side="left", padx=5)
self.delete_btn = ttk.Button(self.button_frame, text="删除选中", command=self.delete_task)
self.delete_btn.pack(side="left", padx=5)
# 任务列表
self.tree = ttk.Treeview(root, columns=("id", "name", "time", "repeat", "content", "status"), show="headings")
self.tree.heading("id", text="ID")
self.tree.heading("name", text="任务名称")
self.tree.heading("time", text="提醒时间")
self.tree.heading("repeat", text="重复")
self.tree.heading("content", text="提醒内容")
self.tree.heading("status", text="状态")
self.tree.column("id", width=50)
self.tree.column("name", width=150)
self.tree.column("time", width=100)
self.tree.column("repeat", width=80)
self.tree.column("content", width=220)
self.tree.column("status", width=80)
self.tree.pack(fill="both", expand=True, padx=10, pady=10)
# 启动检查线程
self.check_thread = threading.Thread(target=self.check_tasks, daemon=True)
self.check_thread.start()
def add_task(self):
task_name = self.task_name_entry.get().strip()
remind_time = self.time_entry.get().strip()
content = self.content_text.get("1.0", tk.END).strip()
repeat = "是" if self.repeat_var.get() else "否"
if not task_name or not remind_time:
messagebox.showwarning("警告", "任务名称和提醒时间不能为空")
return
# 验证时间格式
try:
datetime.strptime(remind_time, "%H:%M:%S")
except ValueError:
messagebox.showwarning("警告", "时间格式不正确,请使用HH:MM:SS格式")
return
task_id = len(self.tasks) + 1
self.tasks.append({
"id": task_id,
"name": task_name,
"time": remind_time,
"repeat": repeat,
"content": content,
"triggered": False,
"status": "待执行"
})
self.update_task_list()
# 清空输入框
self.task_name_entry.delete(0, tk.END)
self.content_text.delete("1.0", tk.END)
self.time_entry.delete(0, tk.END)
self.time_entry.insert(0, time.strftime("%H:%M:%S"))
self.repeat_var.set(False)
messagebox.showinfo("成功", "提醒添加成功")
def update_task_list(self):
# 清空现有数据
for item in self.tree.get_children():
self.tree.delete(item)
for task in self.tasks:
self.tree.insert("", tk.END, values=(
task["id"],
task["name"],
task["time"],
task["repeat"],
task["content"],
task["status"]
))
def delete_task(self):
selected_item = self.tree.selection()
if not selected_item:
messagebox.showwarning("警告", "请先选择要删除的任务")
return
for item in selected_item:
task_id = self.tree.item(item)["values"][0]
self.tasks = [task for task in self.tasks if task["id"] != task_id]
self.update_task_list()
messagebox.showinfo("成功", "任务删除成功")
def check_tasks(self):
while self.running:
current_time = time.strftime("%H:%M:%S")
current_date = time.strftime("%Y-%m-%d")
for task in self.tasks:
if task["time"] == current_time and not task["triggered"]:
# 触发提醒
self.show_reminder(task)
if task["repeat"] == "否":
task["triggered"] = True
task["status"] = "已完成"
else:
# 重复任务重置触发状态
task["triggered"] = False
# 每天0点重置所有重复任务的触发状态
if current_time == "00:00:00":
for task in self.tasks:
if task["repeat"] == "是":
task["triggered"] = False
task["status"] = "待执行"
self.update_task_list()
time.sleep(1)
def show_reminder(self, task):
# 播放提醒音
try:
if os.name == "nt":
winsound.Beep(1000, 1000)
else:
# Linux/macOS 播放提示音
os.system('play -nq -t alsa synth 1 sine 440')
except:
pass
# 弹出提醒窗口
reminder_window = tk.Toplevel(self.root)
reminder_window.title("定时提醒")
reminder_window.geometry("400x250")
reminder_window.attributes("-topmost", True)
ttk.Label(reminder_window, text=f"🔔 提醒时间到!", font=('Arial', 16, 'bold')).pack(pady=20)
ttk.Label(reminder_window, text=f"任务名称: {task['name']}", font=('Arial', 12)).pack(pady=5)
ttk.Label(reminder_window, text=f"提醒时间: {task['time']}", font=('Arial', 12)).pack(pady=5)
ttk.Label(reminder_window, text=f"提醒内容:", font=('Arial', 12)).pack(pady=5)
content_label = ttk.Label(reminder_window, text=task['content'], font=('Arial', 12), wraplength=350)
content_label.pack(pady=5)
def close_reminder():
reminder_window.destroy()
ttk.Button(reminder_window, text="知道了", command=close_reminder).pack(pady=20)
def close_event(self):
self.running = False
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = ReminderTool(root)
root.protocol("WM_DELETE_WINDOW", app.close_event)
root.mainloop()
使用说明: 添加提醒时时间格式为HH:MM:SS,支持每天重复提醒,到点会自动弹出提醒窗口并播放提示音。
项目8:数据可视化工具(PyQt6 + Matplotlib实现)
功能:支持CSV/Excel数据导入、多种图表生成、导出图片
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QFileDialog, QComboBox, QLabel, QMessageBox)
from PyQt6.QtCore import Qt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import pandas as pd
import sys
import os
class DataVisualizationTool(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Python数据可视化工具")
self.setGeometry(100, 100, 1000, 700)
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.layout = QVBoxLayout(self.central_widget)
self.df = None
# 工具栏
self.toolbar_layout = QHBoxLayout()
self.import_btn = QPushButton("导入数据")
self.import_btn.clicked.connect(self.import_data)
self.toolbar_layout.addWidget(self.import_btn)
self.chart_type_label = QLabel("图表类型:")
self.toolbar_layout.addWidget(self.chart_type_label)
self.chart_type_combo = QComboBox()
self.chart_type_combo.addItems(["折线图", "柱状图", "饼图", "散点图", "直方图", "箱线图"])
self.chart_type_combo.currentTextChanged.connect(self.plot_chart)
self.toolbar_layout.addWidget(self.chart_type_combo)
self.x_axis_label = QLabel("X轴:")
self.toolbar_layout.addWidget(self.x_axis_label)
self.x_axis_combo = QComboBox()
self.x_axis_combo.currentTextChanged.connect(self.plot_chart)
self.toolbar_layout.addWidget(self.x_axis_combo)
self.y_axis_label = QLabel("Y轴:")
self.toolbar_layout.addWidget(self.y_axis_label)
self.y_axis_combo = QComboBox()
self.y_axis_combo.currentTextChanged.connect(self.plot_chart)
self.toolbar_layout.addWidget(self.y_axis_combo)
self.export_btn = QPushButton("导出图片")
self.export_btn.clicked.connect(self.export_chart)
self.export_btn.setEnabled(False)
self.toolbar_layout.addWidget(self.export_btn)
self.layout.addLayout(self.toolbar_layout)
# 图表显示区
self.figure = Figure(figsize=(10, 6), dpi=100)
self.canvas = FigureCanvas(self.figure)
self.layout.addWidget(self.canvas)
# 状态栏
self.status_bar = self.statusBar()
self.status_bar.showMessage("请导入数据文件")
def import_data(self):
file_name, _ = QFileDialog.getOpenFileName(self, "导入数据", "", "数据文件 (*.csv *.xlsx *.xls);;所有文件 (*.*)")
if not file_name:
return
try:
if file_name.endswith('.csv'):
self.df = pd.read_csv(file_name)
else:
self.df = pd.read_excel(file_name)
# 更新轴选择框
self.x_axis_combo.clear()
self.y_axis_combo.clear()
columns = self.df.columns.tolist()
self.x_axis_combo.addItems(columns)
self.y_axis_combo.addItems(columns)
self.export_btn.setEnabled(True)
self.status_bar.showMessage(f"成功导入数据: {os.path.basename(file_name)},共{len(self.df)}行{len(self.df.columns)}列")
# 自动绘制第一张图表
if len(columns) >= 2:
self.x_axis_combo.setCurrentIndex(0)
self.y_axis_combo.setCurrentIndex(1)
self.plot_chart()
except Exception as e:
QMessageBox.critical(self, "错误", f"导入数据失败: {str(e)}")
def plot_chart(self):
if self.df is None or self.x_axis_combo.currentText() == "" or self.y_axis_combo.currentText() == "":
return
x_col = self.x_axis_combo.currentText()
y_col = self.y_axis_combo.currentText()
chart_type = self.chart_type_combo.currentText()
self.figure.clear()
ax = self.figure.add_subplot(111)
try:
if chart_type == "折线图":
ax.plot(self.df[x_col], self.df[y_col], marker='o', linewidth=2)
ax.set_title(f"{y_col} 随 {x_col} 变化折线图")
elif chart_type == "柱状图":
ax.bar(self.df[x_col].astype(str), self.df[y_col])
ax.set_title(f"{y_col} 柱状图")
plt.setp(ax.get_xticklabels(), rotation=45, ha='right')
elif chart_type == "饼图":
# 饼图只使用Y轴数据
data = self.df[y_col].value_counts()
ax.pie(data.values, labels=data.index, autopct='%1.1f%%')
ax.set_title(f"{y_col} 占比饼图")
elif chart_type == "散点图":
ax.scatter(self.df[x_col], self.df[y_col], alpha=0.6)
ax.set_title(f"{x_col} vs {y_col} 散点图")
elif chart_type == "直方图":
ax.hist(self.df[y_col], bins=20, edgecolor='black')
ax.set_title(f"{y_col} 分布直方图")
ax.set_xlabel(y_col)
ax.set_ylabel("频数")
elif chart_type == "箱线图":
ax.boxplot(self.df[y_col].dropna())
ax.set_title(f"{y_col} 箱线图")
ax.set_ylabel(y_col)
ax.grid(True, alpha=0.3)
self.figure.tight_layout()
self.canvas.draw()
except Exception as e:
QMessageBox.warning(self, "警告", f"绘制图表失败: {str(e)}")
def export_chart(self):
if self.df is None:
return
file_name, _ = QFileDialog.getSaveFileName(self, "导出图片", "", "图片文件 (*.png *.jpg *.jpeg *.pdf);;所有文件 (*.*)")
if file_name:
try:
self.figure.savefig(file_name, dpi=300, bbox_inches='tight')
QMessageBox.information(self, "成功", "图片导出成功")
except Exception as e:
QMessageBox.critical(self, "错误", f"导出失败: {str(e)}")
if __name__ == "__main__":
app = QApplication(sys.argv)
tool = DataVisualizationTool()
tool.show()
sys.exit(app.exec())
使用说明: 支持导入CSV和Excel格式的数据文件,选择X轴和Y轴字段即可自动生成图表,可导出为高清图片。
三、新手常见踩坑指南
很多新手在Python GUI开发过程中会遇到各种问题,这里总结了最常见的几类问题及解决方案。
3.1 界面布局问题
1. 固定布局导致适配问题
很多新手习惯直接指定控件的绝对位置和大小,导致在不同分辨率下界面变形。建议使用布局管理器(Tkinter的pack/grid/place,PyQt的QVBoxLayout/QHBoxLayout/QGridLayout),实现自适应布局。
2. 控件重叠和显示不全
不要混用不同的布局管理器,比如在同一个窗口同时使用pack和grid,会导致布局冲突。如果需要复杂布局,可以使用Frame进行分层。
3. 窗口大小无法调整
如果希望窗口可以自由缩放,需要给布局设置weight属性,让控件可以随窗口大小自动调整。
3.2 事件处理错误
1. 界面卡顿无响应
不要在主线程中执行耗时操作(如网络请求、文件读写、复杂计算),会阻塞UI事件循环。必须使用多线程/多进程处理耗时任务,完成后再通过信号机制更新UI。
2. 事件绑定失效
绑定事件时不要直接调用函数(如`command=self.func()`),应该传递函数引用(如`command=self.func`)。如果需要传递参数,可以使用lambda表达式。
3. 内存泄漏和资源未释放
打开的文件、数据库连接、摄像头/视频流等资源一定要在窗口关闭时手动释放,避免资源泄漏。PyQt中要善用closeEvent事件处理清理工作。
3.3 打包发布异常
1. 打包后体积过大
推荐使用PyInstaller打包,打包时添加`--onefile`参数生成单文件,使用`--noconsole`隐藏控制台窗口,还可以使用`--exclude-module`排除不需要的库,或者使用upx压缩进一步减小体积。
2. 打包后运行报错
常见原因是缺少动态库或资源文件,打包时需要使用`--add-data`参数包含静态资源,对于PyQt项目需要添加`--windowed`参数。如果是导入自定义模块错误,可以使用`--paths`指定模块路径。
3. 跨平台打包问题
Windows下打包的exe只能在Windows运行,macOS和Linux需要在对应系统下重新打包。跨平台发布推荐使用PySide6(LGPL协议更宽松),避免PyQt的GPL协议问题。
四、Python GUI开发变现盈利方向
掌握Python GUI开发技术后,可以通过以下几个方向实现变现:
4.1 桌面应用定制开发
很多中小微企业和个体工商户有定制化工具需求,比如库存管理系统、客户关系管理系统、订单处理系统、数据统计工具等,这类需求往往预算不高、周期短,Python GUI开发的高性价比优势非常明显,单个项目报价一般在5000-50000元不等。
4.2 企业内部工具开发
大型企业内部有很多重复性工作可以通过GUI工具自动化,比如批量文件处理、报表生成、数据同步、运维工具等,这类工具不需要复杂的界面,只要能解决实际问题即可,适合Python快速开发的特点,长期合作收益稳定。
4.3 GUI培训课程和内容创作
随着Python的普及,越来越多的人想要学习GUI开发,你可以录制培训课程、写作技术教程、开发实战项目课程,在各大知识付费平台售卖,或者做技术UP主通过广告和带货变现,长期收益可观。
4.4 独立产品开发
你可以开发通用型桌面工具(如格式转换器、数据处理工具、效率工具等),通过付费下载、订阅制、增值服务等方式盈利,做得好的独立工具年收入可达几十万甚至上百万。
五、总结
Python GUI开发是一项非常实用的技能,学习曲线平缓、开发效率高、应用场景广泛,无论是作为副业接单还是主业开发都非常有前景。通过本文提供的8个实战项目,你已经掌握了从简单到复杂的GUI开发技巧,接下来只需要不断练习,结合实际需求开发更多项目,就能成为Python GUI开发高手。
