【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()
相关推荐
“AI国潮设计-小江”4 小时前
《Python+SDXL实战:用ControlNet精准控制“英歌舞翻糖吐司”构图,附批量生成脚本》
开发语言·人工智能·python·prompt·aigc
程序喵大人4 小时前
【C++入门】编译链接模型 - 01 一个 C++ 程序是怎样变成可执行文件的
开发语言·c++·编译链接模型
wenwen_chao4 小时前
Python学习与开发资源合集:实用技巧与工具推荐
python
for_ever_love__5 小时前
函数——参数、lambda、*args与作用域
python·函数·大模型开发·编程基础
东莞市云毅网络有限公司6 小时前
企业资料时效性判定:Last-Modified、正文日期与版本字段三路交叉
python·sqlite·自动化运维·geo·数据监测
Query*6 小时前
深入浅出LangGraph【一】_基础篇
python·ai·langchain
sunshine22 girl6 小时前
Java学习七 Java 项目实战1-项目创建和主页面搭建
java·开发语言·学习
XiaoMaqqqq6 小时前
市面上正规的IP驱动产业新场景新工具哪家强
运维·python·网络协议·tcp/ip
菜鸟~noob2336 小时前
【电子战】第三季预告—波武器与电子系统强力毁伤
开发语言·数据库·matlab·电子战
ShyanZh7 小时前
【Python3基础】19-Socket 与 TCP、UDP 编程
python·网络协议·tcp/ip·udp