【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()
相关推荐
曲幽5 小时前
数据库实战:FastAPI + SQLAlchemy 2.0 + Alembic 从零搭建,踩坑实录
python·fastapi·web·sqlalchemy·db·asyncio·alembic
用户83562907805110 小时前
Python 实现 PowerPoint 形状动画设置
后端·python
ponponon11 小时前
时代的眼泪,nameko 和 eventlet 停止维护后的项目自救,升级和替代之路
python
Flittly11 小时前
【从零手写 ClaudeCode:learn-claude-code 项目实战笔记】(5)Skills (技能加载)
python·agent
敏编程11 小时前
一天一个Python库:pyarrow - 大规模数据处理的利器
python
Flittly13 小时前
【从零手写 ClaudeCode:learn-claude-code 项目实战笔记】(4)Subagents (子智能体)
python·agent
明月_清风20 小时前
Python 装饰器前传:如果不懂“闭包”,你只是在复刻代码
后端·python
明月_清风20 小时前
打破“死亡环联”:深挖 Python 分代回收与垃圾回收(GC)机制
后端·python
ZhengEnCi2 天前
08c. 检索算法与策略-混合检索
后端·python·算法
明月_清风2 天前
Python 内存手术刀:sys.getrefcount 与引用计数的生死时速
后端·python