【Python实战】我开发了一款“诗意”待办软件:MoonTask(附源码+工程化思路)

【Day0】我开发了一款"诗意"待办软件:MoonTask(附源码+工程化思路)

Github

https://github.com/MoonPointer-Byte/MoonTask

前言

市面上的 Todo 软件很多,但大多繁杂。我希望有一款工具,既能记录每日任务,又能在我完成工作后,给我一句古诗词作为精神奖励。于是,MoonTask 诞生了。

本文将分享如何使用 Python + CustomTkinter 开发这款桌面应用,并重点介绍如何将一个简单的脚本进行工程化拆分

🎬 效果演示

🛠️ 技术栈

  • 语言: Python 3.12
  • GUI 框架: CustomTkinter (Tkinter 的现代化封装)
  • 数据存储: JSON
  • 网络请求: Requests (对接今日诗词 API)

💡 核心功能实现

1. 工程化目录结构

为了避免"面条代码",我将项目拆分为 MVC 模式的变体:

  • main.py: 程序入口
  • components.py: 存放日历、卡片等 UI 组件
  • utils.py: 处理数据 IO 和 API 请求
  • theme.py: 统一管理配色

2. 可折叠日历逻辑

日历组件不仅要显示日期,还需要支持"周视图"和"月视图"切换。

核心逻辑在于:当折叠时,利用 datetime 计算当前选中日期所在的那一周,只渲染那 7 个按钮。

python 复制代码
# 核心代码片段
if not self.is_expanded:
    # 筛选出包含当前选中日期的那一周
    cal = [week for week in cal if target_day in week]
3. 多线程获取诗词
为了防止网络请求卡死界面,我使用了 threading:
def fetch_poem(self, callback):
    def run():
        # 请求 API ...
        callback(result)
    threading.Thread(target=run, daemon=True).start()

🚀 如何打包

使用 PyInstaller 将其打包为独立 exe:

复制代码
pip install pyinstaller
pyinstaller --noconsole --onefile --name="MoonTask" main.py

📝 结语

MoonTask 不仅仅是一个工具,更是一种生活方式。希望这个开源项目能帮助到想学习 Python GUI 开发的朋友。

完整源码已开源,欢迎 Star!

部分核心代码

components.py
python 复制代码
import customtkinter as ctk
import calendar
from datetime import datetime
from theme import THEME, FONTS
from utils import PoetryManager


class SmoothSidebar(ctk.CTkFrame):
    def __init__(self, master, on_date_select, on_search_click, **kwargs):
        super().__init__(master, fg_color=THEME["sidebar"], corner_radius=0, **kwargs)
        self.on_date_select = on_date_select
        self.on_search_click = on_search_click
        self.expanded_width = 270
        self.collapsed_width = 65
        self.current_width = self.expanded_width
        self.is_expanded = True
        self.animation_running = False

        self.selected_date_obj = datetime.now()

        self.grid_propagate(False)
        self.pack_propagate(False)

        self.top_frame = ctk.CTkFrame(self, fg_color="transparent", height=50)
        self.top_frame.pack(fill="x", pady=10)

        self.menu_btn = ctk.CTkButton(
            self.top_frame, text="≡", width=40, height=40,
            fg_color="transparent", hover_color=THEME["hover"],
            font=FONTS["icon_lg"], text_color=THEME["text_main"],
            command=self.toggle_sidebar
        )
        self.menu_btn.pack(side="left", padx=(10, 5))

        self.logo_lbl = ctk.CTkLabel(self.top_frame, text="MoonTask", font=FONTS["title"],
                                     text_color=THEME["text_main"])
        self.logo_lbl.pack(side="left", padx=5)

        self.content_frame = ctk.CTkFrame(self, fg_color="transparent")
        self.content_frame.pack(fill="both", expand=True, padx=5, pady=5)

        self.render_expanded_view()
        self.search_btn = ctk.CTkButton(
            self, text="🔍  Search Tasks...", fg_color=THEME["card"],
            text_color=THEME["text_dim"], hover_color=THEME["hover"],
            height=40, anchor="w", command=self.on_search_click
        )
        self.search_btn.pack(side="bottom", fill="x", padx=10, pady=20)

    def toggle_sidebar(self):
        if self.animation_running: return

        self.clear_content()

        target = self.collapsed_width if self.is_expanded else self.expanded_width
        step = -25 if self.is_expanded else 25

        self.is_expanded = not self.is_expanded
        if not self.is_expanded:
            self.logo_lbl.pack_forget()
            self.search_btn.configure(text="🔍", width=40, anchor="center")

        self.animate(target, step, callback=self.on_animation_end)

    def animate(self, target, step, callback):
        self.animation_running = True

        if (step < 0 and self.current_width > target) or (step > 0 and self.current_width < target):
            self.current_width += step
            self.configure(width=self.current_width)
            self.update_idletasks()
            self.after(10, lambda: self.animate(target, step, callback))
        else:
            self.current_width = target
            self.configure(width=target)
            self.animation_running = False
            if callback: callback()

    def on_animation_end(self):
        if self.is_expanded:
            self.logo_lbl.pack(side="left", padx=5)
            self.search_btn.configure(text="🔍  Search Tasks...", width=200, anchor="w")
            self.render_expanded_view()
        else:
            self.render_collapsed_view()

    def clear_content(self):
        for widget in self.content_frame.winfo_children():
            widget.destroy()

    def render_expanded_view(self):
        self.clear_content()

        # 月份标题
        ctk.CTkLabel(self.content_frame, text=self.selected_date_obj.strftime("%B %Y"),
                     font=("Arial", 14, "bold"), text_color=THEME["accent"]).pack(pady=(5, 10))

        grid = ctk.CTkFrame(self.content_frame, fg_color="transparent")
        grid.pack()

        # 星期头
        for i, d in enumerate(["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]):
            ctk.CTkLabel(grid, text=d, width=28, text_color=THEME["text_dim"], font=("Arial", 10)).grid(row=0, column=i)

        cal = calendar.monthcalendar(self.selected_date_obj.year, self.selected_date_obj.month)
        for r, week in enumerate(cal):
            for c, day in enumerate(week):
                if day != 0:
                    self.create_day_btn(grid, day, r + 1, c)

    def render_collapsed_view(self):
        self.clear_content()

        cal = calendar.monthcalendar(self.selected_date_obj.year, self.selected_date_obj.month)
        target_day = self.selected_date_obj.day
        current_week = []
        for week in cal:
            if target_day in week:
                current_week = week
                break

        if not current_week: return

        days_str = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
        for i, day in enumerate(current_week):
            if day != 0:
                row = ctk.CTkFrame(self.content_frame, fg_color="transparent")
                row.pack(pady=2)
                ctk.CTkLabel(row, text=days_str[i], font=("Arial", 9), text_color=THEME["text_dim"]).pack()
                self.create_day_btn(row, day, 0, 0, pack_mode=True)

    def create_day_btn(self, parent, day, r, c, pack_mode=False):
        d_str = f"{self.selected_date_obj.year}-{self.selected_date_obj.month:02d}-{day:02d}"
        now = datetime.now()
        is_today = (
                    self.selected_date_obj.year == now.year and self.selected_date_obj.month == now.month and day == now.day)
        is_selected = (day == self.selected_date_obj.day)

        fg = THEME["today_highlight"] if is_today else "transparent"
        fg = THEME["selected"] if is_selected else fg
        txt = "#FFF" if (is_today or is_selected) else THEME["text_dim"]
        btn = ctk.CTkButton(
            parent, text=str(day), width=28, height=28, fg_color=fg,
            text_color=txt, corner_radius=8, font=("Arial", 11),
            hover_color=THEME["hover"],
            command=lambda d=d_str: self.handle_click(d)
        )
        if pack_mode:
            btn.pack()
        else:
            btn.grid(row=r, column=c, padx=1, pady=1)

    def handle_click(self, date_str):
        self.selected_date_obj = datetime.strptime(date_str, "%Y-%m-%d")
        if self.is_expanded:
            self.render_expanded_view()
        else:
            self.render_collapsed_view()
        self.on_date_select(date_str)


class CelebrationFrame(ctk.CTkFrame):
    def __init__(self, master, reset_command, **kwargs):
        super().__init__(master, fg_color=THEME["bg"], **kwargs)
        self.reset_command = reset_command
        self.poem_manager = PoetryManager()

        center = ctk.CTkFrame(self, fg_color="transparent")
        center.place(relx=0.5, rely=0.5, anchor="center")

        ctk.CTkLabel(center, text="🌕", font=("Arial", 64)).pack(pady=(0, 20))
        self.poem_content = ctk.CTkLabel(center, text="...", font=FONTS["poem"], text_color=THEME["text_main"],
                                         wraplength=500, justify="center")
        self.poem_content.pack(pady=15)
        self.poem_info = ctk.CTkLabel(center, text="", font=("Microsoft YaHei UI", 12), text_color=THEME["text_dim"])
        self.poem_info.pack(pady=(0, 30))

        ctk.CTkButton(center, text="返回清单", fg_color="transparent", border_width=1, border_color=THEME["text_dim"],
                      text_color=THEME["text_dim"], command=self.reset_command).pack(pady=20)

    def refresh(self):
        self.poem_content.configure(text="正在寻觅佳句...")
        self.poem_manager.fetch_poem(self.update_ui)

    def update_ui(self, data):
        self.poem_content.configure(text=data.get("content", ""))
        self.poem_info.configure(text=f"------ {data.get('author', '')} · 《{data.get('origin', '')}》")
main.py
python 复制代码
# main.py
import customtkinter as ctk
from datetime import datetime
from theme import THEME, FONTS
from utils import DataManager
from components import SmoothSidebar, CelebrationFrame


class MoonTaskApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("MoonTask Desktop Pro")
        self.geometry("1000x680")
        self.configure(fg_color=THEME["bg"])

        self.data = DataManager.load_data()
        self.current_date = datetime.now().strftime("%Y-%m-%d")

        # 布局配置
        self.grid_columnconfigure(0, weight=0)  # 侧边栏列 (宽度由侧边栏组件自己控制)
        self.grid_columnconfigure(1, weight=1)  # 主内容列
        self.grid_rowconfigure(0, weight=1)

        self.setup_ui()
        self.refresh_task_list()

    def setup_ui(self):
        # 1. 侧边栏
        self.sidebar = SmoothSidebar(
            self,
            on_date_select=self.switch_date,
            on_search_click=self.handle_search
        )
        self.sidebar.grid(row=0, column=0, sticky="nsew")

        # 2. 主区域
        self.main_area = ctk.CTkFrame(self, fg_color="transparent")
        self.main_area.grid(row=0, column=1, sticky="nsew", padx=30, pady=30)

        # 标题
        self.date_title = ctk.CTkLabel(self.main_area, text="Today", font=("Microsoft YaHei UI", 32, "bold"),
                                       text_color=THEME["text_main"])
        self.date_title.pack(anchor="w", pady=(0, 20))

        # 任务滚动区
        self.scroll_frame = ctk.CTkScrollableFrame(self.main_area, fg_color="transparent",
                                                   scrollbar_button_color=THEME["sidebar"])
        self.scroll_frame.pack(fill="both", expand=True)

        # 输入框
        input_frame = ctk.CTkFrame(self.main_area, fg_color=THEME["sidebar"], height=60, corner_radius=30)
        input_frame.pack(fill="x", pady=(20, 0))

        self.entry = ctk.CTkEntry(input_frame, placeholder_text="Add a new task...", border_width=0,
                                  fg_color="transparent",
                                  text_color=THEME["text_main"], font=FONTS["main"], height=50)
        self.entry.pack(side="left", fill="x", expand=True, padx=20)
        self.entry.bind("<Return>", lambda e: self.add_task())

        # 祝贺遮罩
        self.celebration = CelebrationFrame(self, reset_command=self.hide_celebration)

    def switch_date(self, date_str):
        self.current_date = date_str
        self.date_title.configure(text=date_str if date_str != datetime.now().strftime("%Y-%m-%d") else "Today")
        self.refresh_task_list()
        self.hide_celebration()

    def handle_search(self):
        print("Search button clicked! Implement your search logic here.")
        dialog = ctk.CTkInputDialog(text="Search tasks:", title="Search")
        query = dialog.get_input()
        print(f"Searching for: {query}")

    def refresh_task_list(self):
        for w in self.scroll_frame.winfo_children(): w.destroy()
        tasks = self.data.get(self.current_date, [])
        for t in tasks: self.create_task_row(t)

    def create_task_row(self, text):
        f = ctk.CTkFrame(self.scroll_frame, fg_color=THEME["card"], corner_radius=10)
        f.pack(fill="x", pady=5)
        ctk.CTkCheckBox(f, text="", width=24, height=24, corner_radius=12, fg_color=THEME["accent"],
                        border_color=THEME["accent"],
                        hover_color=THEME["gold"], command=lambda: self.complete_task(text, f)).pack(side="left",
                                                                                                     padx=15, pady=15)
        ctk.CTkLabel(f, text=text, font=FONTS["main"], text_color=THEME["text_main"]).pack(side="left", pady=15)

    def add_task(self):
        text = self.entry.get().strip()
        if not text: return
        if self.current_date not in self.data: self.data[self.current_date] = []
        self.data[self.current_date].append(text)
        DataManager.save_data(self.data)
        self.refresh_task_list()
        self.entry.delete(0, "end")

    def complete_task(self, text, widget):
        self.after(500, lambda: self._remove(text, widget))

    def _remove(self, text, widget):
        if text in self.data.get(self.current_date, []):
            self.data[self.current_date].remove(text)
        widget.destroy()
        DataManager.save_data(self.data)

        if self.current_date == datetime.now().strftime("%Y-%m-%d") and not self.data[self.current_date]:
            self.celebration.place(relx=0, rely=0, relwidth=1, relheight=1)
            self.celebration.refresh()

    def hide_celebration(self):
        self.celebration.place_forget()


if __name__ == "__main__":
    ctk.set_appearance_mode("Dark")
    app = MoonTaskApp()
    app.mainloop()
相关推荐
sg_knight2 分钟前
CentOS 裸机实操:5分钟完成 MinIO 单机部署与公网访问
linux·python·centos·文件管理·minio·ftp·oss
IT方大同5 分钟前
(实时操作系统)线程管理
c语言·开发语言·嵌入式硬件
rebekk8 分钟前
PyTorch Dispatcher介绍
人工智能·pytorch·python
呱牛do it18 分钟前
企业级软件研发团队绩效考核系统开发(持续更新 Day 8)
python·fastapi·研发管理
阿kun要赚马内20 分钟前
Python面向对象:@property装饰器
开发语言·前端·python
sunwenjian88622 分钟前
Java进阶--IO流
java·开发语言
wildlily842723 分钟前
C++ Primer 第5版章节题 第十三章(二)
开发语言·c++
测试199827 分钟前
Python+Excel读取和存储测试数据完成接口自动化测试
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·接口测试
曲幽30 分钟前
FastAPI + Celery 实战:异步任务的坑与解法,我帮你踩了一遍
redis·python·fastapi·web·async·celery·background·task·queue
意法半导体STM3234 分钟前
【官方原创】STM32H7双核芯片通过 STlink连接失败问题分析 LAT1654
开发语言·前端·javascript·stm32·单片机·嵌入式硬件