用 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 从零按你的规则表实现一遍,最后打包成可执行文件发给家人试用。