【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()
相关推荐
JaydenAI2 小时前
[拆解LangChain执行引擎]非常规Pending Write的持久化
python·langchain
~央千澈~2 小时前
抖音弹幕游戏开发之第11集:礼物触发功能·优雅草云桧·卓伊凡
java·前端·python
程序员徐师兄2 小时前
Python 基于深度学习的电影评论可视化系统
python·深度学习·深度学习的电影评论可视化系统·深度学习评论情感分析
喵手2 小时前
Python爬虫实战:容错的艺术 - 基于错误分级的自适应重试与退避机制实战
爬虫·python·爬虫实战·容错·零基础python爬虫教学·错误分级·退避机制
wuqingshun3141592 小时前
说一下HashMap和HashTable的区别
java·开发语言
程序员徐师兄2 小时前
基于 Python 深度学习的电影评论情感分析算法
python·深度学习·算法·电影情感分析算法·评论情感分析
沐知全栈开发2 小时前
Bootstrap 多媒体对象
开发语言
PythonFun2 小时前
WPS动态序号填充,告别手动调整烦恼
java·前端·python
Hx_Ma162 小时前
测试题(二)
java·开发语言