开发技巧

ChatGPT + Codex 实战:制作可撤销的桌面文件整理器

用 ChatGPT 理清需求和边界情况,用 Codex CLI 写出一个真正能双击运行的桌面小软件:把杂乱的下载文件夹按类型自动归类,先预览再执行,并且能一键撤销,最后用 PyInstaller 打包成单个可执行文件

BINBHO · 图文教程

用 ChatGPT 理清需求和边界情况,用 Codex CLI 写出一个真正能双击运行的桌面小软件:把杂乱的下载文件夹按类型自动归类,先预览再执行,并且能一键撤销,最后用 PyInstaller 打包成单个可执行文件。

先看清成品

成品是“文件夹整理器”:选择文件夹后列出每个文件将被移动到哪个分类(图片、文档、压缩包、音视频、安装包、其他),确认后才真正移动,重名文件自动改名不覆盖,整理记录写入隐藏日志,可以完整撤销。Python 标准库 tkinter 界面,核心逻辑有 5 个自动化测试。文中代码已在 Linux 上实际运行界面、通过测试并成功打包。

做完之后你会得到什么?

成果 说明
桌面程序 窗口界面,选文件夹、预览、整理、撤销
安全设计 预览不动文件;重名不覆盖;可撤销
单文件可执行程序 Windows 得到 .exe,macOS 得到 .app,Linux 得到可执行文件
测试 分类、跳过规则、重名、整理与撤销往返

需要准备哪些环境?

项目 要求 检查命令
Python 3.10 或更高,带 tkinter python3 -c "import tkinter; print(tkinter.TkVersion)"
Codex CLI 已安装并登录 codex --version
Git 任意较新版本 git --version
打包工具 PyInstaller(本文用 6.22.3 验证) pyinstaller --version

Windows 和 macOS 从 python.org 安装的 Python 自带 tkinter。Debian/Ubuntu 需要额外安装:

sudo apt install python3-tk

注意:PyInstaller 不能跨平台打包。要 Windows 版就在 Windows 上打包,要 macOS 版就在 Mac 上打包。

第一步:怎样用 ChatGPT 想清楚“会出什么事”?

动文件的软件,最重要的不是功能,而是不能把用户的文件搞丢。先让 ChatGPT 帮你把风险列全。

1. 发送需求梳理提示词

我要做一个桌面小工具:把一个文件夹里的文件按类型移动到子文件夹(图片、文档、压缩包、音视频、安装包、其他)。
用户是普通电脑用户,最怕文件丢失。
请列出:
1. 必须有的安全机制
2. 至少 8 个边界情况(例如重名、隐藏文件、子文件夹、文件被占用、权限不足、中文文件名)以及每种情况应有的行为
3. 界面上需要哪些按钮和提示文字
不要写代码。

2. 整理成规则表

本项目最终采用的规则:

情况 行为
点“选择文件夹”后 只预览,不移动任何文件
子文件夹 跳过,不进入
以点开头的隐藏文件 跳过
目标位置已有同名文件 改名为 名字 (1).扩展名,绝不覆盖
本次计划内两个文件重名 同样依次编号
扩展名大小写 不区分,.JPG 也算图片
移动出错 弹窗显示错误原文
整理完成 写入 .tidy-undo.json,可撤销
撤销 逆序移回,删除变空的分类文件夹

3. 让 ChatGPT 写 AGENTS.md

把上面的规则整理成 AGENTS.md,约束如下:
- Python 3.10+,只用标准库,界面用 tkinter + ttk
- 逻辑在 tidy/core.py(不导入 tkinter),界面在 tidy/app.py,入口 main.py
- 测试用 pytest,放 tests/,用 tmp_path 夹具,绝不操作真实用户目录
- 每次修改后运行 pytest 并报告
控制在 40 行以内。

第二步:怎样让 Codex 写出软件?

1. 建项目和虚拟环境

mkdir folder-tidy && cd folder-tidy
git init
python3 -m venv .venv
source .venv/bin/activate        # Windows 用 .venv\Scripts\activate
pip install pytest pyinstaller
echo ".venv/" > .gitignore
# 保存 ChatGPT 生成的 AGENTS.md
git add -A && git commit -m "规格"
codex

2. 发送生成提示词

按 AGENTS.md 实现文件夹整理器。
tidy/core.py 提供:
- category_of(path) -> 分类名
- plan(folder) -> list[Move],Move 是 frozen dataclass,字段 src、dst,只计算不移动
- apply(folder, moves) -> 移动数量,并写 .tidy-undo.json
- undo(folder) -> 恢复数量
tidy/app.py:顶部三个按钮(选择文件夹、开始整理、撤销上次整理)和当前路径;
中间 Treeview 两列“文件 / 将移动到”;底部状态栏。开始整理前弹确认框。
tests/test_core.py 覆盖:分类、跳过子文件夹和隐藏文件、plan 不移动文件、重名编号、整理后撤销能完全还原。
完成后运行 python -m pytest -q。

3. 检查文件结构

folder-tidy/
├── AGENTS.md
├── main.py
├── tidy/
│   ├── __init__.py
│   ├── core.py
│   └── app.py
└── tests/
    └── test_core.py

4. 跑测试

python -m pytest -q

期望输出 5 passed。

关键代码长什么样?

1. 核心逻辑 tidy/core.py

"""整理逻辑:只计算和执行移动计划,不涉及界面,方便测试。"""
from __future__ import annotations

import json
import shutil
from dataclasses import dataclass
from pathlib import Path

CATEGORIES = {
    "图片": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".bmp", ".svg"},
    "文档": {".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".csv"},
    "压缩包": {".zip", ".rar", ".7z", ".tar", ".gz"},
    "音视频": {".mp3", ".wav", ".flac", ".mp4", ".mov", ".mkv", ".avi"},
    "安装包": {".exe", ".msi", ".dmg", ".pkg", ".deb", ".apk"},
}
OTHER = "其他"
LOG_NAME = ".tidy-undo.json"


@dataclass(frozen=True)
class Move:
    src: Path
    dst: Path


def category_of(path: Path) -> str:
    ext = path.suffix.lower()
    for name, exts in CATEGORIES.items():
        if ext in exts:
            return name
    return OTHER


def _unique(dst: Path, taken: set[Path]) -> Path:
    """目标重名时追加 (1)、(2)……,既不覆盖已有文件,也不和本次计划里的其他文件冲突。"""
    candidate, n = dst, 1
    while candidate.exists() or candidate in taken:
        candidate = dst.with_name(f"{dst.stem} ({n}){dst.suffix}")
        n += 1
    return candidate


def plan(folder: Path) -> list[Move]:
    folder = Path(folder)
    known = set(CATEGORIES) | {OTHER}
    moves: list[Move] = []
    taken: set[Path] = set()
    for p in sorted(folder.iterdir()):
        if p.is_dir() or p.name.startswith(".") or p.name == LOG_NAME:
            continue  # 跳过子文件夹和隐藏文件
        dst = _unique(folder / category_of(p) / p.name, taken)
        taken.add(dst)
        moves.append(Move(p, dst))
    return moves


def apply(folder: Path, moves: list[Move]) -> int:
    folder = Path(folder)
    done = []
    for m in moves:
        m.dst.parent.mkdir(exist_ok=True)
        shutil.move(str(m.src), str(m.dst))
        done.append({"src": str(m.src), "dst": str(m.dst)})
    (folder / LOG_NAME).write_text(json.dumps(done, ensure_ascii=False, indent=2), encoding="utf-8")
    return len(done)


def undo(folder: Path) -> int:
    folder = Path(folder)
    log = folder / LOG_NAME
    if not log.exists():
        return 0
    records = json.loads(log.read_text(encoding="utf-8"))
    count = 0
    for r in reversed(records):
        src, dst = Path(r["src"]), Path(r["dst"])
        if dst.exists() and not src.exists():
            shutil.move(str(dst), str(src))
            count += 1
    for d in folder.iterdir():  # 清理变空的分类文件夹
        if d.is_dir() and d.name in set(CATEGORIES) | {OTHER} and not any(d.iterdir()):
            d.rmdir()
    log.unlink()
    return count

plan 和 apply 分开是整个软件安全的关键:界面上看到的预览,就是之后真正执行的计划。

2. 界面 tidy/app.py

"""桌面界面:选择文件夹 → 预览 → 整理 → 可撤销。"""
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

from tidy import core


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("文件夹整理器")
        self.geometry("760x480")
        self.folder: Path | None = None
        self.moves: list[core.Move] = []

        bar = ttk.Frame(self, padding=8)
        bar.pack(fill="x")
        ttk.Button(bar, text="选择文件夹", command=self.choose).pack(side="left")
        self.run_btn = ttk.Button(bar, text="开始整理", command=self.run, state="disabled")
        self.run_btn.pack(side="left", padx=6)
        self.undo_btn = ttk.Button(bar, text="撤销上次整理", command=self.undo, state="disabled")
        self.undo_btn.pack(side="left")
        self.path_var = tk.StringVar(value="尚未选择文件夹")
        ttk.Label(bar, textvariable=self.path_var).pack(side="left", padx=10)

        self.tree = ttk.Treeview(self, columns=("file", "to"), show="headings")
        self.tree.heading("file", text="文件")
        self.tree.heading("to", text="将移动到")
        self.tree.column("file", width=420)
        self.tree.pack(fill="both", expand=True, padx=8, pady=(0, 8))

        self.status = tk.StringVar()
        ttk.Label(self, textvariable=self.status, padding=(8, 0, 8, 8)).pack(anchor="w")

    def choose(self):
        d = filedialog.askdirectory(title="选择要整理的文件夹")
        if d:
            self.folder = Path(d)
            self.refresh()

    def refresh(self):
        self.path_var.set(str(self.folder))
        self.moves = core.plan(self.folder)
        self.tree.delete(*self.tree.get_children())
        for m in self.moves:
            self.tree.insert("", "end", values=(m.src.name, m.dst.relative_to(self.folder)))
        self.status.set(f"共 {len(self.moves)} 个文件待整理(仅预览,尚未移动)")
        self.run_btn["state"] = "normal" if self.moves else "disabled"
        has_log = (self.folder / core.LOG_NAME).exists()
        self.undo_btn["state"] = "normal" if has_log else "disabled"

    def run(self):
        if not messagebox.askyesno("确认", f"将移动 {len(self.moves)} 个文件,确定吗?"):
            return
        try:
            n = core.apply(self.folder, self.moves)
        except OSError as e:
            messagebox.showerror("出错了", str(e))
            return
        self.refresh()
        self.status.set(f"已整理 {n} 个文件,可点“撤销上次整理”恢复")

    def undo(self):
        n = core.undo(self.folder)
        self.refresh()
        self.status.set(f"已恢复 {n} 个文件")


def main():
    App().mainloop()


if __name__ == "__main__":
    main()

3. 入口 main.py

from tidy.app import main

main()

4. 测试 tests/test_core.py

from pathlib import Path

from tidy import core


def make(folder: Path, *names):
    for n in names:
        (folder / n).write_text("x", encoding="utf-8")


def test_category():
    assert core.category_of(Path("a.JPG")) == "图片"
    assert core.category_of(Path("b.pdf")) == "文档"
    assert core.category_of(Path("c.unknown")) == core.OTHER


def test_plan_skips_dirs_and_hidden(tmp_path):
    make(tmp_path, "a.png", ".hidden", "b.zip")
    (tmp_path / "sub").mkdir()
    names = sorted(m.src.name for m in core.plan(tmp_path))
    assert names == ["a.png", "b.zip"]


def test_plan_is_dry_run(tmp_path):
    make(tmp_path, "a.png")
    core.plan(tmp_path)
    assert (tmp_path / "a.png").exists()


def test_name_conflict(tmp_path):
    (tmp_path / "图片").mkdir()
    make(tmp_path / "图片", "a.png")
    make(tmp_path, "a.png")
    [m] = core.plan(tmp_path)
    assert m.dst.name == "a (1).png"


def test_apply_and_undo_roundtrip(tmp_path):
    make(tmp_path, "a.png", "b.pdf", "c.xyz")
    moves = core.plan(tmp_path)
    assert core.apply(tmp_path, moves) == 3
    assert (tmp_path / "图片" / "a.png").exists()
    assert (tmp_path / core.LOG_NAME).exists()
    assert core.undo(tmp_path) == 3
    assert sorted(p.name for p in tmp_path.iterdir()) == ["a.png", "b.pdf", "c.xyz"]

所有测试都在 pytest 提供的临时目录里进行,不会碰到你电脑上的真实文件。

第三步:怎样验收?

1. 准备一个测试文件夹

mkdir -p ~/tidy-demo && cd ~/tidy-demo
touch 照片.JPG 合同.pdf 资料.zip 会议录音.mp3 setup.exe 未知.xyz .secret
mkdir 旧文件夹
cd -

注意:第一次一定用专门的测试文件夹,不要直接拿真实的下载目录试。

2. 运行程序

python main.py

3. 逐项检查

检查项 操作 通过标准
预览 选择 ~/tidy-demo 列出 6 个文件,不含 .secret 和 旧文件夹
预览不动文件 去文件管理器看 文件都还在原处
分类 看“将移动到”列 照片.JPG 到图片,未知.xyz 到其他
整理 点开始整理并确认 出现分类子文件夹,状态栏显示已整理 6 个
重名 再放一个 合同.pdf 进根目录并整理 变成 文档/合同 (1).pdf
撤销 点撤销上次整理 上一次整理的文件回到原处

4. 提交

git add -A && git commit -m "文件夹整理器第一版"

第四步:怎样打包成可执行文件?

1. 打包命令

pyinstaller --noconfirm --onefile --windowed --name 文件夹整理器 main.py
参数 作用
--onefile 打成单个文件
--windowed 不弹出命令行黑窗口
--name 输出文件名
--noconfirm 覆盖上次的输出不再询问

2. 检查结果

产物在 dist/ 目录。Windows 是 文件夹整理器.exe,macOS 是 文件夹整理器.app,Linux 是同名可执行文件。

本文在 Linux 上打出的单文件约 12 MB。双击或在终端运行它,应该和 python main.py 表现一致。

3. 发给别人时要注意

没有代码签名的程序,Windows 可能弹出 SmartScreen 提示,macOS 可能提示“无法验证开发者”。自用或发给熟人可以按系统提示放行,正式分发需要购买代码签名证书。

注意:不要从来源不明的地方下载别人打包好的同类程序,桌面程序有完整的文件读写权限。

出问题时该怎么跟 AI 说?

1. 打包时报找不到 Python 共享库

在 Debian/Ubuntu 上用系统 Python 打包时,本文实际遇到了这个错误:

ERROR: Python shared library ('libpython3.13.so.1.0') was not found!

安装对应版本的库即可(版本号按报错里的改):

sudo apt install libpython3.13

也可以把报错原文直接发给 Codex:

运行 pyinstaller 打包时报错:(粘贴完整报错)。
请告诉我原因和在当前系统上的修复命令,不要修改项目代码。

2. 整理大文件夹时界面卡死

tkinter 在主线程里移动几千个文件时,窗口会显示“未响应”。

整理 3000 个文件时窗口会卡住几十秒。请把 apply 放到后台线程执行,
通过 self.after 定时轮询进度并更新状态栏,执行期间禁用所有按钮。
core.py 只允许增加一个可选的进度回调参数,现有测试必须仍然通过。

3. 移动到一半出错,撤销不完整

本文的 apply 是全部移动完才写日志。如果中途出错,已移动的文件不会被记录。

如果 apply 在第 50 个文件时因为权限不足失败,前 49 个文件不会写入撤销日志。
请改为每移动一个就追加记录,出错时也保留已完成部分的日志,
并补一个测试:用 monkeypatch 让 shutil.move 在第二次调用时抛出 PermissionError,验证撤销能恢复第一个文件。

4. 中文文件名在 Windows 上显示乱码

在 Windows 上整理后,撤销日志里的中文路径是乱码。请检查所有读写文本的地方,统一指定 encoding="utf-8"。

本文代码已经在读写日志时显式指定了 UTF-8。

常见报错怎么处理?

现象 原因 解决方法
No module named 'tkinter' Linux 系统 Python 没装 tk sudo apt install python3-tk
No module named 'tidy' 不在项目根目录运行 在 main.py 所在目录执行
externally-managed-environment 直接往系统 Python 里 pip install 先建虚拟环境再安装
libpython3.x.so not found 系统缺少 Python 共享库 安装对应的 libpython3.x 包
PermissionError 文件被其他程序占用或没有权限 关闭占用程序;界面会显示错误原文
打包后双击没反应 程序启动时报错但窗口被隐藏 去掉 --windowed 重新打包,在终端运行看报错
macOS 提示无法打开 未签名程序 在“系统设置 → 隐私与安全性”中允许
撤销按钮是灰色 当前文件夹没有撤销日志 只能撤销最近一次整理

还能怎么扩展?

方向 提示词
自定义规则 “把分类规则改为读取 rules.json,界面上提供编辑入口”
按日期归档 “新增模式:图片按修改日期放入 2026/10 这样的子文件夹”
定时整理 “增加命令行模式 python main.py --auto 路径,方便用系统定时任务调用”
拖拽 “支持把文件夹拖到窗口上直接预览”

项目资源

用途 地址
ChatGPT https://chatgpt.com
Codex CLI 文档 https://developers.openai.com/codex/cli
Codex CLI 源码 https://github.com/openai/codex
tkinter 官方文档 https://docs.python.org/zh-cn/3/library/tkinter.html
ttk 控件 https://docs.python.org/zh-cn/3/library/tkinter.ttk.html
pytest 临时目录 https://docs.pytest.org/en/stable/how-to/tmp_path.html
PyInstaller 文档 https://pyinstaller.org/en/stable/
Python 下载 https://www.python.org/downloads/

建议先把 tidy/core.py 和测试文件复制下来跑 python -m pytest -q,再在 ~/tidy-demo 这样的测试文件夹上完整走一遍整理和撤销。

确认逻辑可靠后,再让 Codex 从零按你的规则表实现一遍,最后打包成可执行文件发给家人试用。