admin管理员组

文章数量:1027947

PyQt 日签应用制作

前言

前段时间,我迷上了“每日一句”这类 App:一大早起来,滑动屏幕就能看到一句文案,配上一张唯美背景图,仿佛给新的一天打了鸡血。渐渐地,我也想动手做一个桌面版“日签应用”,每天自动切换一句文案和一张背景图,简单、优雅,还能自己折腾界面和功能。

于是,一款基于 PyQt 的“日签应用”就在我的构思中萌芽。这篇博客,我将带你走过从需求收集架构设计,到代码实现调试优化,再到打包发布的全流程,以及我在开发过程中踩到的坑和解决思路,希望对你有所启发。


一、动机与需求

我先聊聊自己的使用场景:

  • 自动化:每天不用手动切换,软件能根据本地时间自动加载当日文案和对应背景。
  • 离线可用:文案和图片可以预先缓存在本地,如果没有网络也能正常显示。
  • 可扩展:未来想接入在线 API、添加更多主题、支持自定义文案集。
  • 简洁美观:界面极简,一句话+一张背景+日期+作者。灯箱式效果,赏心悦目。
  • 轻量易打包:打包成一个可执行,朋友拿去不用配置,打开就能用。

根据这些思路,我草拟了几个核心功能:

  1. 配置管理:管理文案与背景图存放路径、定时更新时刻、缓存策略等;
  2. 本地资源加载:扫描指定文件夹,将文案(JSON/文本)和图片加载到内存或数据库;
  3. 定时刷新:用 QTimer 根据本地时间,切换到当日文案;
  4. 界面展示:自定义窗口风格,通过 QSS 美化,使用 QLabel、QGraphicsView 展示文字和图片;
  5. 离线优先,在线备选:如果本地缓存不存在当日资源,再尝试向网络请求(备用 API);
  6. 打包发布:用 PyInstaller 一键生成可执行。

有了这些需求,下一步就要做整体架构设计,避免一头热写到一半逻辑凌乱。


二、整体架构与模块剖析

在编码之前,我习惯把整个项目拆成几个模块,画一张流程图理清各模块之间的交互。

在这里插入图片描述

模块划分

  • MainWindow:主窗口,承载 UI 布局、信号连接、系统托盘行为。
  • ConfigManager:管理用户设置(如资源路径、定时刷新时刻等),读写 JSON 配置文件。
  • ResourceManager:负责加载、缓存和提供“日签”资源,包括本地扫描与在线下载。
  • Cache:本地资源管理,按“年月日”组织子文件夹,便于查找与过期清理。
  • OnlineFetcher:备用模块,可选地从网络 API 拉取每日文案与图片。
  • TimerController:定时器逻辑,每天一点或应用启动时触发一次刷新,也支持手动调用。
  • SystemTray:系统托盘图标、右键菜单、双击还原窗口。

有了这个高层结构,接下来依次对各模块落地,并在 MainWindow 中组装。


三、项目目录与文件结构

在本地新建项目文件夹 daily_quote_app/,并组织如下目录结构:

代码语言:python代码运行次数:0运行复制
daily_quote_app/
├── main.py
├── main_window.py
├── config_manager.py
├── resource_manager.py
├── timer_controller.py
├── online_fetcher.py
├── system_tray.py
├── resources/
│   ├── icons/
│   │   ├── tray.png
│   │   ├── refresh.png
│   │   └── settings.png
│   ├── style.qss
│   └── default_quotes.json
├── cache/          # 应用自动创建,用于存放缓存资源
└── config.json     # 存储用户设置
  • resources/:静态资源,包括图标、QSS 样式、内置文案 JSON
  • cache/:按 cache/YYYY-MM-DD/ 存放对应日期的文案与背景图
  • config.json:记录资源目录、刷新时刻、在线 API 地址等配置

创建好文件夹后,先用命令生成 resources_rc.py

代码语言:bash复制
pyrcc5 resources/resources.qrc -o resources_rc.py

这一步会将图标、QSS、默认文案等打包成 Python 模块,方便在代码中通过 :/icons/tray.png 等路径引用。


四、配置管理 ConfigManager

配置管理决定应用的灵活性。这里我选择用 JSON 存储配置,简单易读,也便于手动修改。

代码语言:python代码运行次数:0运行复制
# config_manager.py

import json
import os

class ConfigManager:
    def __init__(self, config_path="config.json"):
        self.config_path = config_path
        # 默认配置
        self.config = {
            "refresh_time": "00:00",         # 每天几点刷新日签
            "use_online": False,             # 是否启用在线抓取
            "online_api": "",                # 在线 API 地址
            "resource_dir": "cache",         # 本地缓存目录
            "theme": "light"                 # 主题风格:light/dark
        }
        self.load()

    def load(self):
        if os.path.exists(self.config_path):
            try:
                with open(self.config_path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                self.config.update(data)
            except Exception as e:
                print(f"加载配置失败,使用默认设置:{e}")
                self.save()
        else:
            self.save()

    def save(self):
        try:
            with open(self.config_path, "w", encoding="utf-8") as f:
                json.dump(self.config, f, ensure_ascii=False, indent=4)
            print("配置已保存。")
        except Exception as e:
            print(f"保存配置失败:{e}")

    def get(self, key):
        return self.config.get(key)

    def set(self, key, value):
        self.config[key] = value
        self.save()

关键点解析

  • ensure_ascii=False 保证中文不被转义;
  • indent=4 让 JSON 可读性更好;
  • 在加载失败时,自动重写文件,避免反复报错。

五、资源管理 ResourceManager

  • 职责:根据日期提供当天文案及图片路径;
  • 策略:先查本地 cache/YYYY-MM-DD/,若资源齐全,则直接返回;否则,从内置 JSON 或在线接口取得资源后,存入缓存并返回。
代码语言:python代码运行次数:0运行复制
# resource_manager.py

import os
import json
import shutil
from datetime import datetime
from PyQt5.QtCore import pyqtSignal, QObject

class ResourceManager(QObject):
    updated = pyqtSignal(str, str)  # 文案文本, 图片路径

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.cache_dir = config.get("resource_dir")
        os.makedirs(self.cache_dir, exist_ok=True)
        # 内置默认文案
        with open("resources/default_quotes.json", "r", encoding="utf-8") as f:
            self.default_quotes = json.load(f)

    def get_today(self):
        today = datetime.now().strftime("%Y-%m-%d")
        today_dir = os.path.join(self.cache_dir, today)
        txt_path = os.path.join(today_dir, "quote.txt")
        img_path = os.path.join(today_dir, "bg.jpg")
        # 本地缓存检查
        if os.path.exists(txt_path) and os.path.exists(img_path):
            with open(txt_path, "r", encoding="utf-8") as f:
                text = f.read()
            self.updated.emit(text, img_path)
            return

        # 否则,准备缓存目录
        shutil.rmtree(today_dir, ignore_errors=True)
        os.makedirs(today_dir, exist_ok=True)

        # 尝试在线获取
        if self.config.get("use_online"):
            from online_fetcher import OnlineFetcher
            quote, img_data = OnlineFetcher(self.config.get("online_api")).fetch()
        else:
            # 从内置列表随机挑一句
            import random
            entry = random.choice(self.default_quotes)
            quote = entry["text"]
            img_data = None  # 外置无需下载图片

        # 写入本地
        with open(txt_path, "w", encoding="utf-8") as f:
            f.write(quote)
        if img_data:
            # 在线返回的是二进制
            with open(img_path, "wb") as f:
                f.write(img_data)
        else:
            # 本地内置背景图,可以在 resources 下准备若干图片,随机复制一个
            bg_list = os.listdir("resources/backgrounds")
            src = os.path.join("resources/backgrounds", random.choice(bg_list))
            shutil.copy(src, img_path)

        self.updated.emit(quote, img_path)

细节说明

  • default_quotes.json 存了一组内置的 { "date": "...", "text": "..." } 或纯 [{ "text": "...", "author": "..." }, ...]
  • 在线接口返回 (quote_text, image_binary),自定义 OnlineFetcher 读取。
  • 本地背景图放 resources/backgrounds/,随机选一张。
  • 使用 shutil.rmtree 清理旧缓存,避免文件残留。

六、在线获取 OnlineFetcher

如果用户开启 use_online,就调用在线 API。如下示例,假设接口返回 JSON:

代码语言:json复制
{
  "text": "今日份文案……",
  "image_url": ".jpg"
}

实现时要注意超时与异常处理:

代码语言:python代码运行次数:0运行复制
# online_fetcher.py

import requests
from PyQt5.QtCore import QThread, pyqtSignal

class OnlineFetcher:
    def __init__(self, api_url, timeout=5):
        self.api_url = api_url
        self.timeout = timeout

    def fetch(self):
        try:
            resp = requests.get(self.api_url, timeout=self.timeout)
            data = resp.json()
            text = data.get("text", "")
            # 下载图片二进制
            img_data = requests.get(data.get("image_url", ""), timeout=self.timeout).content
            return text, img_data
        except Exception as e:
            print(f"在线获取失败:{e}")
            return "今日在线获取失败,换个姿势吧~", None

若环境不便安装 requests,也可切换到标准库 urllib


七、定时刷新 TimerController

刷新有两种触发方式:应用启动时每天设定时刻。我用 QTimer 每分钟检查一次当前时间是否到刷新时刻(配置里的 refresh_time),也支持手动点击“刷新”按钮。

代码语言:python代码运行次数:0运行复制
# timer_controller.py

from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from datetime import datetime

class TimerController(QObject):
    trigger = pyqtSignal()

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.timer = QTimer(self)
        self.timer.timeout.connect(self._check_time)
        self.timer.start(60 * 1000)  # 每分钟检查一次

    def _check_time(self):
        now = datetime.now().strftime("%H:%M")
        if now == self.config.get("refresh_time"):
            self.trigger.emit()

MainWindow 中,既要连接 trigger 信号,也要在应用启动后 立即 调用一次,保证打开时显示的是当天最新日签。


八、主界面 MainWindow

UI 核心是一个全屏或可拖拽、无边框的小窗体,显示背景图和一句文案,也要有“刷新”按钮和“设置”按钮。示例布局:

代码语言:python代码运行次数:0运行复制
# main_window.py

from PyQt5.QtWidgets import QMainWindow, QLabel, QPushButton, QVBoxLayout, QWidget
from PyQt5.QtGui import QPixmap, QFont, QPalette, QColor
from PyQt5.QtCore import Qt
from config_manager import ConfigManager
from resource_manager import ResourceManager
from timer_controller import TimerController
from system_tray import SystemTray
import resources_rc

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.config = ConfigManager()
        self.rm = ResourceManager(self.config)
        self.tc = TimerController(self.config)
        self.tray = SystemTray(self)
        self._init_ui()
        self._connect_signals()
        # 首次刷新
        self.refresh()

    def _init_ui(self):
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
        self.resize(600, 400)
        # 背景标签
        self.bg_label = QLabel(self)
        self.bg_label.setScaledContents(True)
        self.bg_label.setGeometry(0, 0, 600, 400)
        # 文案标签
        self.quote_label = QLabel("", self)
        self.quote_label.setWordWrap(True)
        self.quote_label.setAlignment(Qt.AlignCenter)
        self.quote_label.setFont(QFont("楷体", 24))
        self.quote_label.setStyleSheet("color: white;")
        self.quote_label.setGeometry(20, 100, 560, 200)
        # 刷新按钮
        self.btn_refresh = QPushButton(QIcon(":/icons/refresh.png"), "", self)
        self.btn_refresh.setGeometry(550, 350, 32, 32)
        # 设置按钮
        self.btn_settings = QPushButton(QIcon(":/icons/settings.png"), "", self)
        self.btn_settings.setGeometry(510, 350, 32, 32)

    def _connect_signals(self):
        self.btn_refresh.clicked.connect(self.refresh)
        self.rm.updated.connect(self._update_ui)
        self.tc.trigger.connect(self.refresh)

    def refresh(self):
        self.rm.get_today()

    def _update_ui(self, text, img_path):
        pix = QPixmap(img_path).scaled(self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
        self.bg_label.setPixmap(pix)
        self.quote_label.setText(text)

    def mousePressEvent(self, event):
        # 无边框窗体拖拽
        self._drag_pos = event.globalPos()

    def mouseMoveEvent(self, event):
        if event.buttons() == Qt.LeftButton:
            self.move(self.pos() + event.globalPos() - self._drag_pos)
            self._drag_pos = event.globalPos()

    def closeEvent(self, event):
        event.ignore()
        self.hide()
        self.tray.showMessage("日签应用已最小化到托盘", "双击恢复窗口")

要点提示

  • 设置无边框 Qt.FramelessWindowHint,并用 mousePressEvent / mouseMoveEvent 实现窗口拖动;
  • 背景图片用 scaled 保证铺满窗口并保持比例;
  • 文案用半透明字体或加阴影效果,可在 QSS 中增强可读性;
  • “刷新”、“设置”按钮放在角落,方便点击。

九、系统托盘 SystemTray

托盘交互与之前项目类似,右键菜单可“显示”、“设置”、“退出”。

代码语言:python代码运行次数:0运行复制
# system_tray.py

from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon
import resources_rc

class SystemTray(QSystemTrayIcon):
    def __init__(self, window):
        super().__init__(QIcon(":/icons/tray.png"), window)
        self.window = window
        menu = QMenu()
        show_act = QAction("显示主窗口", window)
        exit_act = QAction("退出", window)
        menu.addAction(show_act)
        menu.addAction(exit_act)
        self.setContextMenu(menu)
        show_act.triggered.connect(self._show_window)
        exit_act.triggered.connect(window.close)
        self.activated.connect(self._on_activated)
        self.show()

    def _show_window(self):
        self.window.show()

    def _on_activated(self, reason):
        if reason == QSystemTrayIcon.DoubleClick:
            self._show_window()

十、样式美化与 QSS

为了让日签更有质感,我在 resources/style.qss 中写了如下片段:

代码语言:css复制
QMainWindow {
    background: transparent;
}

QLabel {
    color: #ffffff;
    text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
}

QPushButton {
    border: none;
    background: rgba(255,255,255,0.3);
    border-radius: 4px;
}

QPushButton:hover {
    background: rgba(255,255,255,0.5);
}

并在 main.py 中加载:

代码语言:python代码运行次数:0运行复制
# main.py

import sys
from PyQt5.QtWidgets import QApplication
from main_window import MainWindow
import resources_rc

if __name__ == "__main__":
    app = QApplication(sys.argv)
    with open("resources/style.qss", "r", encoding="utf-8") as f:
        app.setStyleSheet(f.read())
    win = MainWindow()
    win.show()
    sys.exit(app.exec_())

十一、异常处理与容错

开发过程中,我遇到过几种意外情况:

  1. 缓存文件损坏或缺失:undefinedopen(txt_path) 时报错,导致应用卡住。解决:在 ResourceManager.get_todaytry/except,失败后删除当日缓存目录重试或使用内置文案。
  2. 在线接口不可用:undefinedrequests.get 超时或返回非 JSON。解决:捕获所有异常并返回 fallback 文案。
  3. 图片格式不支持:undefined一些远程图片为 WebP,PyQt 默认可能无法加载。解决:可使用 PIL(Pillow)先转换成 JPEG,再传入。
  4. 窗口位置超屏:undefined如果上次运行时拖到了多屏偏移位置,打开后窗口可能不可见。解决:在启动时判断 win.geometry(),若超出主屏可视区,则居中显示。

十二、打包发布

完成所有功能后,就用 PyInstaller 打包,命令如下:

代码语言:bash复制
pyinstaller --noconfirm --clean --windowed \
    --name DailyQuote \
    --add-data "resources/;resources/" \
    main.py
  • --windowed:不弹命令行黑框;
  • --add-data:把 resources/ 目录打包进去;
  • 打包后生成 dist/DailyQuote/,包含可执行和依赖。

用户只需下载此文件夹,双击运行 DailyQuote.exe(或无后缀可执行文件)即可体验每天一句的小确幸。


在这里插入图片描述

总结

从思考动机,到需求拆解,再到架构图、模块落地、代码实现、细节优化,最后到美化和打包,整个“日签应用”开发过程,既是一场 PyQt 的深度研习,也是一次 桌面小工具 的完整落地实践。

希望这篇 开发纪实,不仅分享了核心思路和代码片段,也能帮助你掌握如何从零构建一个可维护、易扩展的 PyQt 项目。若你正好也想做个“桌面日签”“待办小助手”“天气预报”“文件管理”等工具,不妨借鉴本文的模块划分与工作流程。

最后,祝你编码顺利,每天都有一句好文案,给工作和生活增添一丝小确幸。

PyQt 日签应用制作

前言

前段时间,我迷上了“每日一句”这类 App:一大早起来,滑动屏幕就能看到一句文案,配上一张唯美背景图,仿佛给新的一天打了鸡血。渐渐地,我也想动手做一个桌面版“日签应用”,每天自动切换一句文案和一张背景图,简单、优雅,还能自己折腾界面和功能。

于是,一款基于 PyQt 的“日签应用”就在我的构思中萌芽。这篇博客,我将带你走过从需求收集架构设计,到代码实现调试优化,再到打包发布的全流程,以及我在开发过程中踩到的坑和解决思路,希望对你有所启发。


一、动机与需求

我先聊聊自己的使用场景:

  • 自动化:每天不用手动切换,软件能根据本地时间自动加载当日文案和对应背景。
  • 离线可用:文案和图片可以预先缓存在本地,如果没有网络也能正常显示。
  • 可扩展:未来想接入在线 API、添加更多主题、支持自定义文案集。
  • 简洁美观:界面极简,一句话+一张背景+日期+作者。灯箱式效果,赏心悦目。
  • 轻量易打包:打包成一个可执行,朋友拿去不用配置,打开就能用。

根据这些思路,我草拟了几个核心功能:

  1. 配置管理:管理文案与背景图存放路径、定时更新时刻、缓存策略等;
  2. 本地资源加载:扫描指定文件夹,将文案(JSON/文本)和图片加载到内存或数据库;
  3. 定时刷新:用 QTimer 根据本地时间,切换到当日文案;
  4. 界面展示:自定义窗口风格,通过 QSS 美化,使用 QLabel、QGraphicsView 展示文字和图片;
  5. 离线优先,在线备选:如果本地缓存不存在当日资源,再尝试向网络请求(备用 API);
  6. 打包发布:用 PyInstaller 一键生成可执行。

有了这些需求,下一步就要做整体架构设计,避免一头热写到一半逻辑凌乱。


二、整体架构与模块剖析

在编码之前,我习惯把整个项目拆成几个模块,画一张流程图理清各模块之间的交互。

在这里插入图片描述

模块划分

  • MainWindow:主窗口,承载 UI 布局、信号连接、系统托盘行为。
  • ConfigManager:管理用户设置(如资源路径、定时刷新时刻等),读写 JSON 配置文件。
  • ResourceManager:负责加载、缓存和提供“日签”资源,包括本地扫描与在线下载。
  • Cache:本地资源管理,按“年月日”组织子文件夹,便于查找与过期清理。
  • OnlineFetcher:备用模块,可选地从网络 API 拉取每日文案与图片。
  • TimerController:定时器逻辑,每天一点或应用启动时触发一次刷新,也支持手动调用。
  • SystemTray:系统托盘图标、右键菜单、双击还原窗口。

有了这个高层结构,接下来依次对各模块落地,并在 MainWindow 中组装。


三、项目目录与文件结构

在本地新建项目文件夹 daily_quote_app/,并组织如下目录结构:

代码语言:python代码运行次数:0运行复制
daily_quote_app/
├── main.py
├── main_window.py
├── config_manager.py
├── resource_manager.py
├── timer_controller.py
├── online_fetcher.py
├── system_tray.py
├── resources/
│   ├── icons/
│   │   ├── tray.png
│   │   ├── refresh.png
│   │   └── settings.png
│   ├── style.qss
│   └── default_quotes.json
├── cache/          # 应用自动创建,用于存放缓存资源
└── config.json     # 存储用户设置
  • resources/:静态资源,包括图标、QSS 样式、内置文案 JSON
  • cache/:按 cache/YYYY-MM-DD/ 存放对应日期的文案与背景图
  • config.json:记录资源目录、刷新时刻、在线 API 地址等配置

创建好文件夹后,先用命令生成 resources_rc.py

代码语言:bash复制
pyrcc5 resources/resources.qrc -o resources_rc.py

这一步会将图标、QSS、默认文案等打包成 Python 模块,方便在代码中通过 :/icons/tray.png 等路径引用。


四、配置管理 ConfigManager

配置管理决定应用的灵活性。这里我选择用 JSON 存储配置,简单易读,也便于手动修改。

代码语言:python代码运行次数:0运行复制
# config_manager.py

import json
import os

class ConfigManager:
    def __init__(self, config_path="config.json"):
        self.config_path = config_path
        # 默认配置
        self.config = {
            "refresh_time": "00:00",         # 每天几点刷新日签
            "use_online": False,             # 是否启用在线抓取
            "online_api": "",                # 在线 API 地址
            "resource_dir": "cache",         # 本地缓存目录
            "theme": "light"                 # 主题风格:light/dark
        }
        self.load()

    def load(self):
        if os.path.exists(self.config_path):
            try:
                with open(self.config_path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                self.config.update(data)
            except Exception as e:
                print(f"加载配置失败,使用默认设置:{e}")
                self.save()
        else:
            self.save()

    def save(self):
        try:
            with open(self.config_path, "w", encoding="utf-8") as f:
                json.dump(self.config, f, ensure_ascii=False, indent=4)
            print("配置已保存。")
        except Exception as e:
            print(f"保存配置失败:{e}")

    def get(self, key):
        return self.config.get(key)

    def set(self, key, value):
        self.config[key] = value
        self.save()

关键点解析

  • ensure_ascii=False 保证中文不被转义;
  • indent=4 让 JSON 可读性更好;
  • 在加载失败时,自动重写文件,避免反复报错。

五、资源管理 ResourceManager

  • 职责:根据日期提供当天文案及图片路径;
  • 策略:先查本地 cache/YYYY-MM-DD/,若资源齐全,则直接返回;否则,从内置 JSON 或在线接口取得资源后,存入缓存并返回。
代码语言:python代码运行次数:0运行复制
# resource_manager.py

import os
import json
import shutil
from datetime import datetime
from PyQt5.QtCore import pyqtSignal, QObject

class ResourceManager(QObject):
    updated = pyqtSignal(str, str)  # 文案文本, 图片路径

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.cache_dir = config.get("resource_dir")
        os.makedirs(self.cache_dir, exist_ok=True)
        # 内置默认文案
        with open("resources/default_quotes.json", "r", encoding="utf-8") as f:
            self.default_quotes = json.load(f)

    def get_today(self):
        today = datetime.now().strftime("%Y-%m-%d")
        today_dir = os.path.join(self.cache_dir, today)
        txt_path = os.path.join(today_dir, "quote.txt")
        img_path = os.path.join(today_dir, "bg.jpg")
        # 本地缓存检查
        if os.path.exists(txt_path) and os.path.exists(img_path):
            with open(txt_path, "r", encoding="utf-8") as f:
                text = f.read()
            self.updated.emit(text, img_path)
            return

        # 否则,准备缓存目录
        shutil.rmtree(today_dir, ignore_errors=True)
        os.makedirs(today_dir, exist_ok=True)

        # 尝试在线获取
        if self.config.get("use_online"):
            from online_fetcher import OnlineFetcher
            quote, img_data = OnlineFetcher(self.config.get("online_api")).fetch()
        else:
            # 从内置列表随机挑一句
            import random
            entry = random.choice(self.default_quotes)
            quote = entry["text"]
            img_data = None  # 外置无需下载图片

        # 写入本地
        with open(txt_path, "w", encoding="utf-8") as f:
            f.write(quote)
        if img_data:
            # 在线返回的是二进制
            with open(img_path, "wb") as f:
                f.write(img_data)
        else:
            # 本地内置背景图,可以在 resources 下准备若干图片,随机复制一个
            bg_list = os.listdir("resources/backgrounds")
            src = os.path.join("resources/backgrounds", random.choice(bg_list))
            shutil.copy(src, img_path)

        self.updated.emit(quote, img_path)

细节说明

  • default_quotes.json 存了一组内置的 { "date": "...", "text": "..." } 或纯 [{ "text": "...", "author": "..." }, ...]
  • 在线接口返回 (quote_text, image_binary),自定义 OnlineFetcher 读取。
  • 本地背景图放 resources/backgrounds/,随机选一张。
  • 使用 shutil.rmtree 清理旧缓存,避免文件残留。

六、在线获取 OnlineFetcher

如果用户开启 use_online,就调用在线 API。如下示例,假设接口返回 JSON:

代码语言:json复制
{
  "text": "今日份文案……",
  "image_url": ".jpg"
}

实现时要注意超时与异常处理:

代码语言:python代码运行次数:0运行复制
# online_fetcher.py

import requests
from PyQt5.QtCore import QThread, pyqtSignal

class OnlineFetcher:
    def __init__(self, api_url, timeout=5):
        self.api_url = api_url
        self.timeout = timeout

    def fetch(self):
        try:
            resp = requests.get(self.api_url, timeout=self.timeout)
            data = resp.json()
            text = data.get("text", "")
            # 下载图片二进制
            img_data = requests.get(data.get("image_url", ""), timeout=self.timeout).content
            return text, img_data
        except Exception as e:
            print(f"在线获取失败:{e}")
            return "今日在线获取失败,换个姿势吧~", None

若环境不便安装 requests,也可切换到标准库 urllib


七、定时刷新 TimerController

刷新有两种触发方式:应用启动时每天设定时刻。我用 QTimer 每分钟检查一次当前时间是否到刷新时刻(配置里的 refresh_time),也支持手动点击“刷新”按钮。

代码语言:python代码运行次数:0运行复制
# timer_controller.py

from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from datetime import datetime

class TimerController(QObject):
    trigger = pyqtSignal()

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.timer = QTimer(self)
        self.timer.timeout.connect(self._check_time)
        self.timer.start(60 * 1000)  # 每分钟检查一次

    def _check_time(self):
        now = datetime.now().strftime("%H:%M")
        if now == self.config.get("refresh_time"):
            self.trigger.emit()

MainWindow 中,既要连接 trigger 信号,也要在应用启动后 立即 调用一次,保证打开时显示的是当天最新日签。


八、主界面 MainWindow

UI 核心是一个全屏或可拖拽、无边框的小窗体,显示背景图和一句文案,也要有“刷新”按钮和“设置”按钮。示例布局:

代码语言:python代码运行次数:0运行复制
# main_window.py

from PyQt5.QtWidgets import QMainWindow, QLabel, QPushButton, QVBoxLayout, QWidget
from PyQt5.QtGui import QPixmap, QFont, QPalette, QColor
from PyQt5.QtCore import Qt
from config_manager import ConfigManager
from resource_manager import ResourceManager
from timer_controller import TimerController
from system_tray import SystemTray
import resources_rc

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.config = ConfigManager()
        self.rm = ResourceManager(self.config)
        self.tc = TimerController(self.config)
        self.tray = SystemTray(self)
        self._init_ui()
        self._connect_signals()
        # 首次刷新
        self.refresh()

    def _init_ui(self):
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
        self.resize(600, 400)
        # 背景标签
        self.bg_label = QLabel(self)
        self.bg_label.setScaledContents(True)
        self.bg_label.setGeometry(0, 0, 600, 400)
        # 文案标签
        self.quote_label = QLabel("", self)
        self.quote_label.setWordWrap(True)
        self.quote_label.setAlignment(Qt.AlignCenter)
        self.quote_label.setFont(QFont("楷体", 24))
        self.quote_label.setStyleSheet("color: white;")
        self.quote_label.setGeometry(20, 100, 560, 200)
        # 刷新按钮
        self.btn_refresh = QPushButton(QIcon(":/icons/refresh.png"), "", self)
        self.btn_refresh.setGeometry(550, 350, 32, 32)
        # 设置按钮
        self.btn_settings = QPushButton(QIcon(":/icons/settings.png"), "", self)
        self.btn_settings.setGeometry(510, 350, 32, 32)

    def _connect_signals(self):
        self.btn_refresh.clicked.connect(self.refresh)
        self.rm.updated.connect(self._update_ui)
        self.tc.trigger.connect(self.refresh)

    def refresh(self):
        self.rm.get_today()

    def _update_ui(self, text, img_path):
        pix = QPixmap(img_path).scaled(self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
        self.bg_label.setPixmap(pix)
        self.quote_label.setText(text)

    def mousePressEvent(self, event):
        # 无边框窗体拖拽
        self._drag_pos = event.globalPos()

    def mouseMoveEvent(self, event):
        if event.buttons() == Qt.LeftButton:
            self.move(self.pos() + event.globalPos() - self._drag_pos)
            self._drag_pos = event.globalPos()

    def closeEvent(self, event):
        event.ignore()
        self.hide()
        self.tray.showMessage("日签应用已最小化到托盘", "双击恢复窗口")

要点提示

  • 设置无边框 Qt.FramelessWindowHint,并用 mousePressEvent / mouseMoveEvent 实现窗口拖动;
  • 背景图片用 scaled 保证铺满窗口并保持比例;
  • 文案用半透明字体或加阴影效果,可在 QSS 中增强可读性;
  • “刷新”、“设置”按钮放在角落,方便点击。

九、系统托盘 SystemTray

托盘交互与之前项目类似,右键菜单可“显示”、“设置”、“退出”。

代码语言:python代码运行次数:0运行复制
# system_tray.py

from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon
import resources_rc

class SystemTray(QSystemTrayIcon):
    def __init__(self, window):
        super().__init__(QIcon(":/icons/tray.png"), window)
        self.window = window
        menu = QMenu()
        show_act = QAction("显示主窗口", window)
        exit_act = QAction("退出", window)
        menu.addAction(show_act)
        menu.addAction(exit_act)
        self.setContextMenu(menu)
        show_act.triggered.connect(self._show_window)
        exit_act.triggered.connect(window.close)
        self.activated.connect(self._on_activated)
        self.show()

    def _show_window(self):
        self.window.show()

    def _on_activated(self, reason):
        if reason == QSystemTrayIcon.DoubleClick:
            self._show_window()

十、样式美化与 QSS

为了让日签更有质感,我在 resources/style.qss 中写了如下片段:

代码语言:css复制
QMainWindow {
    background: transparent;
}

QLabel {
    color: #ffffff;
    text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
}

QPushButton {
    border: none;
    background: rgba(255,255,255,0.3);
    border-radius: 4px;
}

QPushButton:hover {
    background: rgba(255,255,255,0.5);
}

并在 main.py 中加载:

代码语言:python代码运行次数:0运行复制
# main.py

import sys
from PyQt5.QtWidgets import QApplication
from main_window import MainWindow
import resources_rc

if __name__ == "__main__":
    app = QApplication(sys.argv)
    with open("resources/style.qss", "r", encoding="utf-8") as f:
        app.setStyleSheet(f.read())
    win = MainWindow()
    win.show()
    sys.exit(app.exec_())

十一、异常处理与容错

开发过程中,我遇到过几种意外情况:

  1. 缓存文件损坏或缺失:undefinedopen(txt_path) 时报错,导致应用卡住。解决:在 ResourceManager.get_todaytry/except,失败后删除当日缓存目录重试或使用内置文案。
  2. 在线接口不可用:undefinedrequests.get 超时或返回非 JSON。解决:捕获所有异常并返回 fallback 文案。
  3. 图片格式不支持:undefined一些远程图片为 WebP,PyQt 默认可能无法加载。解决:可使用 PIL(Pillow)先转换成 JPEG,再传入。
  4. 窗口位置超屏:undefined如果上次运行时拖到了多屏偏移位置,打开后窗口可能不可见。解决:在启动时判断 win.geometry(),若超出主屏可视区,则居中显示。

十二、打包发布

完成所有功能后,就用 PyInstaller 打包,命令如下:

代码语言:bash复制
pyinstaller --noconfirm --clean --windowed \
    --name DailyQuote \
    --add-data "resources/;resources/" \
    main.py
  • --windowed:不弹命令行黑框;
  • --add-data:把 resources/ 目录打包进去;
  • 打包后生成 dist/DailyQuote/,包含可执行和依赖。

用户只需下载此文件夹,双击运行 DailyQuote.exe(或无后缀可执行文件)即可体验每天一句的小确幸。


在这里插入图片描述

总结

从思考动机,到需求拆解,再到架构图、模块落地、代码实现、细节优化,最后到美化和打包,整个“日签应用”开发过程,既是一场 PyQt 的深度研习,也是一次 桌面小工具 的完整落地实践。

希望这篇 开发纪实,不仅分享了核心思路和代码片段,也能帮助你掌握如何从零构建一个可维护、易扩展的 PyQt 项目。若你正好也想做个“桌面日签”“待办小助手”“天气预报”“文件管理”等工具,不妨借鉴本文的模块划分与工作流程。

最后,祝你编码顺利,每天都有一句好文案,给工作和生活增添一丝小确幸。

本文标签: PyQt 日签应用制作