代码库
空文件夹扫描器
一个用 Python 标准库写成的 Windows 桌面小工具:液态玻璃质感界面,递归扫描指定目录下的全部空文件夹,并支持带多重保护的批量清理。
简介
磁盘用久了总会攒下一堆空目录——卸载残留、解压失败、同步中断留下的空壳。空文件夹扫描器帮你把它们一次性找出来:选好根目录点开始,它在后台线程里递归遍历,界面实时滚动显示发现结果;确认无误后点一下删除,工具会逐个复检目录确实为空、从最深的子目录往上删,并自动清理删空之后的父目录。整个过程不碰扫描根目录,也不碰回收站和系统卷信息目录。
详细介绍
空文件夹扫描器是一个基于 tkinter 的桌面 GUI 工具,用一个 Python 文件实现,不依赖任何第三方库,Python 3.11+ 直接运行。
它解决的是一个具体而琐碎的问题:找出并清掉磁盘上那些"看着占地方、其实什么都没有"的空目录。相比在资源管理器里逐个翻找,或者写一段一次性的命令行脚本,它把三件事合在了一起:
- 可视化扫描:结果实时滑入列表,扫了多少目录、找到多少个,状态栏一目了然
- 安全删除:不是简单地
rmdir一把梭,而是有确认、有复检、有保护、有级联 - 顺手的日常操作:右键直接打开目录或复制路径,结果可导出成 txt 存档
界面走的是液态玻璃风格:深色底板上三层半透明渐变色块缓慢漂移,按钮悬停时有液体从底部涌起、液面还带波纹,进度条是一段在轨道里来回游动的高亮。所有动画都由一个 33 毫秒的定时器驱动,不额外吃线程。
核心特性
扫描
| 能力 | 说明 |
|---|---|
| 递归遍历 | 从指定根目录向下扫描全部层级 |
| 后台执行 | 扫描跑在独立线程,通过队列回传结果,界面全程不卡 |
| 实时反馈 | 状态栏显示"已遍历 N 个目录,发现 M 个空文件夹" |
| 随时中断 | 扫描过程中按钮变为"点击停止扫描",再点一次即停 |
| 跳过系统目录 | $RECYCLE.BIN、System Volume Information 不深入、不计入结果,匹配不区分大小写 |
| 虚拟列表 | 只渲染可视区域内的行,结果上万条也一样流畅 |
判空使用的是剪枝前的真实目录内容,所以"里面只装了一个回收站"的目录不会被误判为空——这一点直接决定了它能不能安全地交给删除功能使用。
一键清理
删除是这个工具最需要谨慎的部分,因此做了五层防护:
- 二次确认:弹窗写明将删除的数量、扫描根目录和全部安全规则,默认焦点停在"否"
- 由深及浅:按路径深度降序删除,先删子目录再删父目录,不会留下孤儿
- 删前复检:每个目录删除前重新
listdir()确认仍为空,再交给os.rmdir()——它本身就拒绝非空目录,等于双保险。扫描之后才新增文件的目录会被跳过 - 级联清理:子目录删完后若父目录随之变空,继续向上清理,遇到非空父目录立即停止
- 范围硬保护:扫描根目录本身永不删除;不在根目录范围内的路径一律不动(Windows 上跨盘符也算范围外)
删除同样在后台线程执行,期间其它按钮自动禁用。完成后结果列表会移除已删项、计数同步更新,状态栏给出完整账单:成功多少个、跳过多少个、多少个已不存在、多少个失败(失败明细最多列出 10 条)。
界面与交互
- 结果栏:左键选中行,右键弹出菜单(打开路径 / 复制路径)
- 所有按钮:左键点击生效
- 打开路径:跨平台实现——Windows 走
os.startfile,macOS 走open,Linux 走xdg-open;目录已不存在时给出提示而不是抛异常 - 导出:结果一键存为 txt,文件头带上扫描根目录和总数
- 进度条:目录总数事先不可知,因此不做假百分比,改用不确定态跑马灯,完成后填满整条
技术实现
- 单文件、纯标准库:
tkinter/os/queue/threading/subprocess/math,无外部依赖 - 线程与 UI 解耦:工作线程只往队列里塞消息,主线程以 50 毫秒间隔轮询消费,避免跨线程操作控件
- 液体动画的正确画法:液面多边形每帧整体重建坐标(顶部是正弦波曲线、底部是圆角),而不是用四个值去改一个多点多边形的位置——后者会把圆角多边形截断成一条直线
- 可测试的核心逻辑:扫描、排序、删除、几何计算、平台命令分派全部抽成不依赖 GUI 的纯函数
- 32 条单元测试:用真实临时目录断言,不使用 mock;覆盖系统目录剪枝、只含系统目录的父目录不算空、级联清理不越过根目录、非空目录跳过、液面坐标不越界、跑马灯始终留在轨道内等场景。测试进程会注入一个只含基类占位符的假
tkinter,因此在无显示器的机器上也能跑
运行与打包
直接运行
python 空文件夹扫描器_v2.py
运行单元测试
python -m unittest discover -s tests
打包成 exe(需在 Windows 上执行)
pip install pyinstaller && pyinstaller --noconfirm --clean --onefile --windowed --name "空文件夹扫描器" "空文件夹扫描器_v2.py"
产物在 dist\空文件夹扫描器.exe,目标机器无需安装 Python。
快速上手
- 在路径框填入或直接粘贴要扫描的目录,也可以点「浏览…」选择
- 点「开始扫描」,等待列表滚动结束,状态栏提示扫描完成
- 右键任意一条结果可以直接打开所在目录或复制路径,确认它确实该删
- 点「删除全部空文件夹」,在确认框里核对数量和根目录,选「是」
- 需要留档就点「导出结果到文件」
提醒:删除操作不可恢复。工具已经尽力做了复检和范围保护,但正式清理重要目录前,先导出结果看一遍仍然值得。
源代码
# -*- coding: utf-8 -*-
"""
液态效果 GUI 空文件夹扫描器 v2
Python 3.11+ | 仅使用内置 tkinter,无需安装第三方库
本版变更:
1. 修复液体填充被 canvas.coords() 截断成一条线的 bug(改为每帧重建波纹多边形)
2. 修复系统目录(回收站 / System Volume Information)未被跳过的问题
—— 原代码在 topdown=False 下修改 dirnames 是无效的
3. 修复进度条取模乱跳的问题(改为不确定态跑马灯),并让"打开路径"跨平台可用
4. 新增「删除全部空文件夹」按钮:二次确认 + 后台线程删除 + 级联清理变空的父目录
交互约定:
- 结果栏:左键选中行,右键弹出菜单(打开路径 / 复制路径)
- 其余所有按钮:左键点击生效
"""
import os
import sys
import math
import queue
import subprocess
import threading
from collections import namedtuple
try:
import tkinter as tk
from tkinter import filedialog, messagebox
except ImportError: # 无图形环境时仍允许导入本模块以运行单元测试
tk = None
# ----------------------- 配色方案(液态玻璃) -----------------------
BG_DARK = "#0d1120"
PANEL = "#151b30"
LIQUID_1 = "#4facfe" # 亮蓝
LIQUID_2 = "#8e2de2" # 紫
LIQUID_3 = "#00f5a0" # 薄荷绿
DANGER_1 = "#ff5f6d" # 危险操作红
DANGER_2 = "#ffc371" # 危险操作橙
TEXT_MAIN = "#e8ecf8"
TEXT_SUB = "#8b93ad"
ITEM_BG = "#1c2340"
ITEM_HOVER = "#252e52"
# Windows 上不应扫描/删除的系统目录(统一小写比较)
SKIP_DIR_NAMES = frozenset({"$recycle.bin", "system volume information"})
# 删除结果报告
DeleteReport = namedtuple(
"DeleteReport",
["deleted", "skipped_nonempty", "missing", "failed", "outside"],
)
# =====================================================================
# 核心逻辑(纯函数,可单元测试,不依赖 tkinter)
# =====================================================================
def iter_empty_dirs(root, skip_names=SKIP_DIR_NAMES, on_dir=None,
should_stop=None):
"""遍历 root,逐个产出"真正的空目录"路径。
- 自顶向下遍历,因此 skip_names 中的系统目录会被真正剪枝、不再深入;
- 判空使用剪枝前的原始 dirnames/filenames,
所以"只含系统目录的父目录"不会被误判为空;
- root 自身不会出现在结果中;
- on_dir(path):每访问一个目录回调一次(用于统计进度);
- should_stop():返回 True 时立即结束遍历。
"""
root_abs = os.path.abspath(root)
skip = {str(s).lower() for s in skip_names}
for dirpath, dirnames, filenames in os.walk(root_abs, topdown=True,
followlinks=False):
if should_stop is not None and should_stop():
return
if on_dir is not None:
on_dir(dirpath)
# 先用剪枝前的真实内容判空:只含系统目录的父目录不算空
is_empty = not dirnames and not filenames
# topdown=True 下剪枝才生效,系统目录不会再被深入
dirnames[:] = [d for d in dirnames if d.lower() not in skip]
if is_empty and os.path.abspath(dirpath) != root_abs:
yield dirpath
def sort_deepest_first(paths):
"""按路径深度降序排序,保证删除时先删子目录再删父目录。"""
return sorted(
paths,
key=lambda p: (len(os.path.normpath(p).split(os.sep)), os.path.normpath(p)),
reverse=True,
)
def _is_within(child, parent):
"""child 是否严格位于 parent 内部(不含 parent 自身)。"""
try:
rel = os.path.relpath(child, parent)
except ValueError: # Windows 上跨盘符
return False
if rel == os.pardir:
return False
return not rel.startswith(os.pardir + os.sep) and rel != os.curdir
def _try_rmdir(path):
"""尝试删除单个目录。
返回 "deleted" / "nonempty" / "missing" / ("failed", 错误信息)
"""
try:
if not os.path.exists(path):
return "missing"
if not os.path.isdir(path):
return "nonempty"
if os.listdir(path): # 删除前重新校验确实为空
return "nonempty"
os.rmdir(path) # rmdir 本身也拒绝非空目录,双保险
return "deleted"
except OSError as e:
return ("failed", f"{type(e).__name__}: {e}")
def delete_empty_dirs(paths, root, cascade=True):
"""删除给定的空目录,返回 DeleteReport。
安全约束:
- 删除顺序为"最深的先删";
- 每个目录在删除前重新校验确实为空(os.rmdir 本身也拒绝非空目录);
- 绝不删除 root 自身;
- 只处理位于 root 范围内的路径,范围外的记入 outside;
- cascade=True 时,删除后若父目录变空且仍在 root 之内,继续向上清理。
"""
root_abs = os.path.abspath(root)
deleted, skipped_nonempty, missing, failed, outside = [], [], [], [], []
def record(original, result):
if result == "deleted":
deleted.append(original)
elif result == "nonempty":
skipped_nonempty.append(original)
elif result == "missing":
missing.append(original)
else:
failed.append((original, result[1]))
for p in sort_deepest_first(paths):
ap = os.path.abspath(p)
if ap == root_abs: # 根目录受保护
skipped_nonempty.append(p)
continue
if not _is_within(ap, root_abs): # 范围外一律不动
outside.append(p)
continue
record(ap, _try_rmdir(ap))
if cascade:
cur = os.path.dirname(ap)
while True:
ca = os.path.abspath(cur)
if ca == root_abs or not _is_within(ca, root_abs):
break
res = _try_rmdir(ca)
if res != "deleted":
break # 父目录非空/失败即停止向上
deleted.append(ca)
cur = os.path.dirname(ca)
return DeleteReport(deleted, skipped_nonempty, missing, failed, outside)
def liquid_points(x1, y1, x2, y2, base_r, wave_amp=0.0, phase=0.0, samples=18):
"""构造"液体"多边形的扁平坐标列表 [x0,y0,x1,y1,...]。
顶部是一条随 phase 起伏的液面曲线,底部是两个圆角。
必须每次整体重建坐标,绝不能用 4 个值调用 canvas.coords()
—— 那会把 14 点的圆角多边形截断成 2 个点(v1 的 bug)。
"""
x1, y1, x2, y2 = float(x1), float(y1), float(x2), float(y2)
height = max(0.0, y2 - y1)
width = max(0.0, x2 - x1)
r = max(0.0, min(float(base_r), height / 2.0, width / 2.0))
amp = max(0.0, float(wave_amp))
n = max(2, int(samples))
pts = []
# 液面:左 → 右 的正弦波
for i in range(n + 1):
pts.append(x1 + width * i / n)
pts.append(y1 + amp * math.sin(phase + i * 0.55))
# 右侧下行 + 底部圆角 + 底边右 → 左 + 左侧上行
pts.extend([x2, y2 - r, x2, y2, x2 - r, y2,
x1 + r, y2, x1, y2, x1, y2 - r])
return pts
def marquee_segment(tick, track_x1, track_x2, seg_w):
"""不确定态进度条:返回第 tick 帧高亮段的 (sx1, sx2)。
高亮段在轨道内来回滑动,任何时刻都完整落在 [track_x1, track_x2] 内;
seg_w 大于轨道长度时收敛为整条轨道。
"""
track_x1, track_x2 = float(track_x1), float(track_x2)
seg_w = max(0.0, float(seg_w))
travel = (track_x2 - track_x1) - seg_w
if travel <= 0:
return track_x1, track_x2
step = 5.0 # 每帧像素
period = 2.0 * travel / step # 一个完整往返所需帧数
phase = (float(tick) % period) / period # 0~1
tri = 2.0 * phase if phase < 0.5 else 2.0 * (1.0 - phase) # 0→1→0
sx1 = track_x1 + travel * tri
return sx1, sx1 + seg_w
def build_open_command(path, platform):
"""按平台返回打开目录的命令列表;Windows 返回 None(走 os.startfile)。"""
p = (platform or "").lower()
if p.startswith("win"):
return None
if p == "darwin":
return ["open", path]
return ["xdg-open", path]
# =====================================================================
# GUI 部分
# =====================================================================
if tk is None:
def rounded_rect(*a, **kw):
raise RuntimeError("tkinter 不可用")
else:
def rounded_rect(canvas, x1, y1, x2, y2, r, **kwargs):
"""在画布上绘制圆角矩形"""
points = [
x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r,
x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2,
x1, y2, x1, y2 - r, x1, y1 + r, x1, y1,
]
return canvas.create_polygon(points, smooth=True, **kwargs)
if tk is not None:
class LiquidButton(tk.Canvas):
"""带液态填充悬停动画的圆角按钮(左键触发 command)"""
def __init__(self, master, text, command, width=180, height=46,
fill1="#4facfe", fill2="#8e2de2", fg="#ffffff", **kw):
super().__init__(master, width=width, height=height,
highlightthickness=0, bd=0, **kw)
self.command = command
self.fill1, self.fill2 = fill1, fill2
self.w, self.h = width, height
self.progress = 0.0 # 液体填充程度 0~1
self.enabled = True
self._job = None
self._phase = 0.0
self.bg_item = rounded_rect(self, 2, 2, width - 2, height - 2,
height // 2, fill="#232a45", outline="")
# 液体多边形(初始藏在底部,高度为 0)
self.liquid = self.create_polygon(
liquid_points(2, height - 2, width - 2, height - 2,
height // 2),
smooth=True, fill=fill1, outline="")
self.text_item = self.create_text(
width / 2, height / 2, text=text, fill=fg,
font=("Microsoft YaHei UI", 11, "bold"))
# ---- 交互:所有按钮统一左键生效 ----
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
self.bind("<Button-1>", self._on_click) # 左键:触发命令
self.configure(cursor="hand2")
def set_enabled(self, on):
self.enabled = bool(on)
self.configure(cursor="hand2" if self.enabled else "arrow")
self.itemconfigure(self.text_item,
fill="#ffffff" if self.enabled else "#6b7391")
def _animate(self, target):
if self._job:
self.after_cancel(self._job)
step = 0.08 if target > self.progress else -0.08
def tick():
self.progress += step
if (step > 0 and self.progress >= target) or \
(step < 0 and self.progress <= target):
self.progress = target
self._phase += 0.35
self._draw_liquid()
if self.progress != target:
self._job = self.after(16, tick)
tick()
def _draw_liquid(self):
"""每帧整体重建液面多边形(修复 v1 被 coords 截断的 bug)"""
h_fill = self.h * self.progress
y_top = max(2.0, self.h - h_fill)
# 液面振幅受顶部余量钳制,避免波纹溢出按钮外
amp = min(3.0, max(0.0, y_top - 2.0)) \
if 0.02 < self.progress < 0.98 else 0.0
pts = liquid_points(2, y_top, self.w - 2, self.h - 2,
self.h // 2, wave_amp=amp, phase=self._phase)
self.coords(self.liquid, *pts)
self.tag_raise(self.liquid, self.bg_item)
self.tag_raise(self.text_item)
def _on_enter(self, _):
if self.enabled:
self._animate(1.0)
def _on_leave(self, _):
self._animate(0.0)
def _on_click(self, _):
if self.enabled and self.command:
self.command()
class VirtualList(tk.Canvas):
"""虚拟列表:只渲染可视区域的行,数据量再大也不卡"""
ROW_H = 28
def __init__(self, master, width=648, height=196, **kw):
super().__init__(master, width=width, height=height,
highlightthickness=0, bd=0, bg="#0f1424", **kw)
self.items = []
self.yoff = 0.0
self.hover_index = -1
self.selected_index = -1
self.on_scroll_change = None
self.bind("<Motion>", self._on_motion)
self.bind("<Leave>", lambda e: self._set_hover(-1))
self.bind("<Button-1>", self._on_left_click) # 左键:选中行
self.bind("<Button-3>", self._on_right_click) # 右键:弹菜单
self.bind("<Configure>", lambda e: self.redraw())
# ---------------- 数据操作 ----------------
def set_items(self, items):
self.items = list(items)
self.yoff = 0.0
self.hover_index = -1
self.selected_index = -1
self._notify_scroll()
self.redraw()
def append(self, text):
self.items.append(text)
if self.yoff >= self.virtual_height() - self.winfo_height() - self.ROW_H:
self.yoff = max(0.0, self.virtual_height() - self.winfo_height())
self._notify_scroll()
self.redraw()
def clear(self):
self.items.clear()
self.yoff = 0.0
self.hover_index = -1
self.selected_index = -1
self._notify_scroll()
self.redraw()
def virtual_height(self):
return len(self.items) * self.ROW_H
# ---------------- 滚动 ----------------
def scrollbar_cmd(self, *args):
if args[0] == "moveto":
f = max(0.0, min(1.0, float(args[1])))
self.yoff = f * max(0.0, self.virtual_height() - self.winfo_height())
self.redraw()
def on_mousewheel(self, delta):
self.yoff -= delta / 120 * self.ROW_H * 3
max_off = max(0.0, self.virtual_height() - self.winfo_height())
self.yoff = max(0.0, min(self.yoff, max_off))
self._notify_scroll()
self.redraw()
def _notify_scroll(self):
v, h = self.virtual_height(), self.winfo_height()
if v <= h or v == 0:
first, last = 0.0, 1.0
else:
first = self.yoff / v
last = (self.yoff + h) / v
if self.on_scroll_change:
self.on_scroll_change(first, last)
# ---------------- 渲染 ----------------
def redraw(self):
self.delete("row")
total = len(self.items)
if total == 0 or self.winfo_width() <= 1:
return
w = self.winfo_width()
start = int(self.yoff // self.ROW_H)
end = min(total, start + self.winfo_height() // self.ROW_H + 2)
y = -int(self.yoff % self.ROW_H)
for i in range(start, end):
if i == self.selected_index:
bg = "#2b3a6b"
elif i == self.hover_index:
bg = ITEM_HOVER
else:
bg = ITEM_BG if i % 2 == 0 else "#182040"
rounded_rect(self, 2, y, w - 2, y + self.ROW_H - 3, 8,
fill=bg, outline="", tags="row")
self.create_text(10, y + self.ROW_H // 2 - 2,
text=f"{i+1:>5} {self.items[i]}",
anchor="w", fill=TEXT_MAIN,
font=("Consolas", 9), tags="row")
y += self.ROW_H
# ---------------- 交互 ----------------
def _row_at(self, y):
return int((self.yoff + y) // self.ROW_H)
def _on_motion(self, e):
idx = self._row_at(e.y)
if idx != self.hover_index:
self._set_hover(idx if 0 <= idx < len(self.items) else -1)
def _set_hover(self, idx):
self.hover_index = idx
self.configure(cursor="hand2" if idx >= 0 else "")
self.redraw()
def _open_path(self, path):
if not os.path.exists(path):
self._flash("路径不存在或已被删除")
return
try:
cmd = build_open_command(path, sys.platform)
if cmd is None:
os.startfile(path) # Windows
else:
subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except Exception:
self._flash(f"无法打开: {os.path.basename(path)}")
def _copy_path(self, path):
self.clipboard_clear()
self.clipboard_append(path)
self._flash("已复制到剪贴板")
def _on_left_click(self, e):
"""左键:选中当前行"""
idx = self._row_at(e.y)
self.selected_index = idx if 0 <= idx < len(self.items) else -1
self.redraw()
def _on_right_click(self, e):
"""右键:先选中该行,再弹出 打开/复制 菜单"""
idx = self._row_at(e.y)
if not (0 <= idx < len(self.items)):
return
self.selected_index = idx
self.redraw()
p = self.items[idx]
menu = tk.Menu(self, tearoff=0, bg=ITEM_BG, fg=TEXT_MAIN,
activebackground=LIQUID_1,
activeforeground="#ffffff",
bd=0, font=("Microsoft YaHei UI", 9))
menu.add_command(label="\U0001f4c2 打开路径",
command=lambda: self._open_path(p))
menu.add_command(label="\U0001f4cb 复制路径",
command=lambda: self._copy_path(p))
try:
menu.tk_popup(e.x_root, e.y_root)
finally:
menu.grab_release()
def _flash(self, msg):
self.delete("flashbg", "flash")
w, h = self.winfo_width(), self.winfo_height()
rounded_rect(self, w * 0.05, h / 2 - 15, w * 0.95, h / 2 + 15,
12, fill=LIQUID_3, outline="", tags="flashbg")
self.create_text(w / 2, h / 2, text=msg, fill="#0d1120",
font=("Microsoft YaHei UI", 10, "bold"),
tags="flash")
self.after(900, lambda: self.delete("flashbg", "flash"))
class LiquidScanner(tk.Tk):
W, H = 760, 660
TRACK_X1, TRACK_X2, TRACK_Y1, TRACK_Y2 = 50, 740, 252, 268
def __init__(self):
super().__init__()
self.title("空文件夹扫描器 v2")
self.geometry(f"{self.W}x{self.H}")
self.configure(bg=BG_DARK)
self.resizable(False, False)
try:
self.attributes("-alpha", 0.96)
except tk.TclError:
pass
self.scan_queue = queue.Queue()
self.scanning = False
self.deleting = False
self.results = []
self.scan_root = None
self._anim_t = 0.0
self._marquee_tick = 0
self._build_ui()
self._animate_background()
# ---------------- UI 构建 ----------------
def _build_ui(self):
W, H = self.W, self.H
self.canvas = tk.Canvas(self, width=W, height=H,
highlightthickness=0, bd=0, bg=BG_DARK)
self.canvas.pack(fill="both", expand=True)
self.blob1 = self.canvas.create_oval(-100, -80, 300, 260,
fill=LIQUID_1,
stipple="gray50", outline="")
self.blob2 = self.canvas.create_oval(W - 260, H - 240, W + 100,
H + 80, fill=LIQUID_2,
stipple="gray50", outline="")
self.blob3 = self.canvas.create_oval(W - 320, -120, W + 60, 220,
fill=LIQUID_3,
stipple="gray25", outline="")
rounded_rect(self.canvas, 24, 24, W - 24, H - 24, 28,
fill=PANEL, outline="#2a3454", width=1)
self.canvas.create_text(52, 72, text="空文件夹扫描器",
fill=TEXT_MAIN,
font=("Microsoft YaHei UI", 20, "bold"),
anchor="w")
self.canvas.create_text(52, 102,
text="扫描磁盘找出空文件夹,可一键清理",
fill=TEXT_SUB,
font=("Microsoft YaHei UI", 10), anchor="w")
# 路径输入
rounded_rect(self.canvas, 50, 130, 560, 174, 20,
fill="#0f1424", outline="#2a3454", width=1)
self.path_var = tk.StringVar(value=os.path.expanduser("~"))
self.path_entry = tk.Entry(self.canvas, textvariable=self.path_var,
bg="#0f1424", fg=TEXT_MAIN,
insertbackground=TEXT_MAIN,
font=("Consolas", 11), bd=0,
highlightthickness=0)
self.path_entry.place(x=68, y=141, width=474, height=22)
self.browse_btn = LiquidButton(self.canvas, "浏览…", self._browse,
width=90, height=44,
fill1=LIQUID_3, fill2=LIQUID_1,
bg=PANEL)
self.browse_btn.place(x=576, y=130)
self.scan_btn = LiquidButton(self.canvas, "开始扫描",
self._start_or_stop,
width=690, height=48,
fill1=LIQUID_1, fill2=LIQUID_2,
bg=PANEL)
self.scan_btn.place(x=50, y=190)
# 进度条
rounded_rect(self.canvas, self.TRACK_X1, self.TRACK_Y1,
self.TRACK_X2, self.TRACK_Y2, 8,
fill="#0f1424", outline="")
self.progress_fill = self.canvas.create_polygon(
liquid_points(self.TRACK_X1, self.TRACK_Y1 + 2,
self.TRACK_X1 + 2, self.TRACK_Y2 - 2, 6),
smooth=True, fill=LIQUID_1, outline="")
self.status_text = self.canvas.create_text(
50, 288, text="就绪", fill=TEXT_SUB,
font=("Microsoft YaHei UI", 9), anchor="w")
# 结果列表
rounded_rect(self.canvas, 50, 310, 740, 560, 18,
fill="#0f1424", outline="#2a3454", width=1)
self.canvas.create_text(70, 334,
text="扫描结果(左键选中 · 右键:打开 / 复制路径)",
fill=TEXT_MAIN,
font=("Microsoft YaHei UI", 11, "bold"),
anchor="w")
self.count_text = self.canvas.create_text(
720, 334, text="0 个空文件夹", fill=LIQUID_3,
font=("Microsoft YaHei UI", 10), anchor="e")
self.result_list = VirtualList(self.canvas, width=644, height=196)
self.result_list.place(x=66, y=350)
self.scroll = tk.Scrollbar(self.canvas, orient="vertical",
command=self.result_list.scrollbar_cmd,
bg=PANEL, troughcolor="#0f1424",
highlightthickness=0, bd=0,
activebackground=LIQUID_1)
self.scroll.place(x=716, y=350, height=196)
self.result_list.on_scroll_change = self._sync_scrollbar
self.bind_all("<MouseWheel>", self._on_wheel)
# 底部两个按钮:导出 / 删除(均左键生效)
self.export_btn = LiquidButton(self.canvas, "导出结果到文件",
self._export, width=335, height=44,
fill1=LIQUID_2, fill2=LIQUID_1,
bg=PANEL)
self.export_btn.place(x=50, y=576)
self.delete_btn = LiquidButton(self.canvas, "删除全部空文件夹",
self._delete_all, width=335,
height=44, fill1=DANGER_1,
fill2=DANGER_2, bg=PANEL)
self.delete_btn.place(x=405, y=576)
self.delete_btn.set_enabled(False)
self._set_busy_buttons(False)
def _set_busy_buttons(self, busy):
"""扫描/删除进行中时禁用其它按钮,避免并发操作"""
self.browse_btn.set_enabled(not busy)
self.export_btn.set_enabled(not busy)
self.delete_btn.set_enabled(not busy and bool(self.results))
if busy:
self.scan_btn.set_enabled(True) # 扫描按钮保留"停止"能力
# ---------------- 背景动画 + 跑马灯进度 ----------------
def _animate_background(self):
t = self._anim_t
W, H = self.W, self.H
self.canvas.coords(self.blob1,
-100 + 40 * math.sin(t * 0.6),
-80 + 30 * math.cos(t * 0.8),
300 + 40 * math.sin(t * 0.6),
260 + 30 * math.cos(t * 0.8))
self.canvas.coords(self.blob2,
W - 260 + 50 * math.cos(t * 0.5),
H - 240 + 40 * math.sin(t * 0.7),
W + 100 + 50 * math.cos(t * 0.5),
H + 80 + 40 * math.sin(t * 0.7))
self.canvas.coords(self.blob3,
W - 320 + 35 * math.sin(t * 0.9 + 2),
-120 + 45 * math.cos(t * 0.4),
W + 60 + 35 * math.sin(t * 0.9 + 2),
220 + 45 * math.cos(t * 0.4))
if self.scanning or self.deleting:
self._marquee_tick += 1
sx1, sx2 = marquee_segment(self._marquee_tick, self.TRACK_X1,
self.TRACK_X2, 140)
self.canvas.coords(
self.progress_fill,
*liquid_points(sx1, self.TRACK_Y1 + 2, sx2,
self.TRACK_Y2 - 2, 6,
wave_amp=1.2, phase=t * 4))
self._anim_t += 0.03
self.after(33, self._animate_background)
def _on_wheel(self, e):
self.result_list.on_mousewheel(e.delta)
def _sync_scrollbar(self, first, last):
self.scroll.set(first, last)
# ---------------- 扫描 ----------------
def _browse(self):
d = filedialog.askdirectory()
if d:
self.path_var.set(d)
def _start_or_stop(self):
if self.scanning:
self.scanning = False
self.canvas.itemconfigure(self.status_text, text="正在停止…")
return
if self.deleting:
return
root = self.path_var.get().strip()
if not root or not os.path.exists(root):
messagebox.showerror("错误", "路径不存在,请重新选择!")
return
self.result_list.clear()
self.results.clear()
self.scan_root = root
self._marquee_tick = 0
self.canvas.itemconfigure(self.count_text, text="0 个空文件夹")
self.canvas.itemconfigure(self.status_text, text="正在扫描…")
self.scanning = True
self.scan_btn.itemconfigure(self.scan_btn.text_item,
text="点击停止扫描")
self._set_busy_buttons(True)
threading.Thread(target=self._scan_worker, args=(root,),
daemon=True).start()
self._poll_queue()
def _scan_worker(self, root):
found = 0
visited = 0
def on_dir(_p):
nonlocal visited
visited += 1
if visited % 50 == 0:
self.scan_queue.put(("dirs", visited, found))
def should_stop():
return not self.scanning
try:
for path in iter_empty_dirs(root, on_dir=on_dir,
should_stop=should_stop):
found += 1
self.scan_queue.put(("found", path, found))
except Exception as e: # pragma: no cover
self.scan_queue.put(("error", str(e), found))
self.scan_queue.put(("done", visited,
found if self.scanning else -1))
def _poll_queue(self):
try:
while True:
msg, payload, count = self.scan_queue.get_nowait()
if msg == "found":
self.results.append(payload)
self.result_list.append(payload)
self.canvas.itemconfigure(self.count_text,
text=f"{count} 个空文件夹")
elif msg == "dirs":
self.canvas.itemconfigure(
self.status_text,
text=f"正在扫描… 已遍历 {payload} 个目录,"
f"发现 {count} 个空文件夹")
elif msg == "delete_progress":
self.canvas.itemconfigure(
self.status_text, text=f"正在删除… 已处理 {payload} 个")
elif msg == "deleted":
self._on_delete_finished(payload)
elif msg == "error":
self.scanning = False
self.deleting = False
self.canvas.itemconfigure(self.status_text,
text=f"出错:{payload}")
self._reset_buttons()
elif msg == "done":
self.scanning = False
visited = payload
self.scan_btn.itemconfigure(self.scan_btn.text_item,
text="开始扫描")
if count >= 0:
self.canvas.coords(
self.progress_fill,
*liquid_points(self.TRACK_X1,
self.TRACK_Y1 + 2,
self.TRACK_X2,
self.TRACK_Y2 - 2, 6))
self.canvas.itemconfigure(
self.status_text,
text=f"扫描完成!遍历 {visited} 个目录,"
f"找到 {count} 个空文件夹")
else:
self.canvas.itemconfigure(self.status_text,
text="扫描已手动停止")
self._reset_buttons()
except queue.Empty:
pass
if self.scanning or self.deleting or not self.scan_queue.empty():
self.after(50, self._poll_queue)
def _reset_buttons(self):
self._set_busy_buttons(False)
# ---------------- 删除全部空文件夹 ----------------
def _delete_all(self):
if self.deleting or self.scanning:
return
if not self.results:
messagebox.showinfo("提示", "暂无可删除的空文件夹,请先扫描。")
return
n = len(self.results)
ok = messagebox.askyesno(
"确认删除",
f"即将永久删除 {n} 个空文件夹(不可恢复)。\n\n"
f"扫描根目录:{self.scan_root}\n\n"
"· 删除前会逐个重新校验目录确实为空\n"
"· 子目录删除后变空的父目录也会被一并清理\n"
"· 扫描根目录本身不会被删除\n\n"
"确定继续吗?",
icon="warning", default="no")
if not ok:
return
self.deleting = True
self._marquee_tick = 0
self.canvas.itemconfigure(self.progress_fill, fill=DANGER_1)
self.canvas.itemconfigure(self.status_text, text="正在删除…")
self.delete_btn.itemconfigure(self.delete_btn.text_item,
text="正在删除…")
self._set_busy_buttons(True)
paths = list(self.results)
threading.Thread(target=self._delete_worker,
args=(paths, self.scan_root), daemon=True).start()
self._poll_queue()
def _delete_worker(self, paths, root):
try:
rep = delete_empty_dirs(paths, root, cascade=True)
except Exception as e: # pragma: no cover
self.scan_queue.put(("error", str(e), 0))
return
self.scan_queue.put(("deleted", rep, len(paths)))
def _on_delete_finished(self, rep):
self.deleting = False
self.canvas.itemconfigure(self.progress_fill, fill=LIQUID_1)
self.delete_btn.itemconfigure(self.delete_btn.text_item,
text="删除全部空文件夹")
deleted = set(rep.deleted)
self.results = [p for p in self.results if p not in deleted]
self.result_list.set_items(self.results)
self.canvas.itemconfigure(self.count_text,
text=f"{len(self.results)} 个空文件夹")
self.canvas.coords(
self.progress_fill,
*liquid_points(self.TRACK_X1, self.TRACK_Y1 + 2,
self.TRACK_X2, self.TRACK_Y2 - 2, 6))
msg = (f"删除完成:成功 {len(rep.deleted)} 个"
f"(含级联清理的父目录)")
if rep.skipped_nonempty:
msg += f",跳过 {len(rep.skipped_nonempty)} 个(已非空/受保护)"
if rep.missing:
msg += f",{len(rep.missing)} 个已不存在"
if rep.outside:
msg += f",{len(rep.outside)} 个不在扫描范围内"
if rep.failed:
msg += f",失败 {len(rep.failed)} 个"
self.canvas.itemconfigure(self.status_text, text=msg)
self._reset_buttons()
detail = ""
if rep.failed:
detail = "\n\n失败明细(最多 10 条):\n" + "\n".join(
f"{p} —— {err}" for p, err in rep.failed[:10])
messagebox.showinfo("删除完成", msg + detail)
# ---------------- 导出 ----------------
def _export(self):
if not self.results:
messagebox.showinfo("提示", "暂无扫描结果!")
return
f = filedialog.asksaveasfilename(
defaultextension=".txt",
initialfile="empty_folders.txt",
filetypes=[("文本文件", "*.txt")])
if f:
with open(f, "w", encoding="utf-8") as fp:
fp.write(f"扫描根目录: {self.scan_root}\n")
fp.write(f"共找到 {len(self.results)} 个空文件夹\n")
fp.write("=" * 60 + "\n")
fp.write("\n".join(self.results))
messagebox.showinfo("完成", f"已导出到:\n{f}")
if __name__ == "__main__":
if tk is None:
raise SystemExit("未检测到 tkinter,请在带图形界面的环境中运行。")
app = LiquidScanner()
app.mainloop()
运行效果:

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_58662768/article/details/166737425




