_AidenSun头像
关注
windows11-空文件夹扫描器封面图

windows11-空文件夹扫描器

代码库

空文件夹扫描器

空文件夹扫描器

一个用 Python 标准库写成的 Windows 桌面小工具:液态玻璃质感界面,递归扫描指定目录下的全部空文件夹,并支持带多重保护的批量清理。

简介

磁盘用久了总会攒下一堆空目录——卸载残留、解压失败、同步中断留下的空壳。空文件夹扫描器帮你把它们一次性找出来:选好根目录点开始,它在后台线程里递归遍历,界面实时滚动显示发现结果;确认无误后点一下删除,工具会逐个复检目录确实为空、从最深的子目录往上删,并自动清理删空之后的父目录。整个过程不碰扫描根目录,也不碰回收站和系统卷信息目录。

详细介绍

空文件夹扫描器是一个基于 tkinter 的桌面 GUI 工具,用一个 Python 文件实现,不依赖任何第三方库,Python 3.11+ 直接运行。

它解决的是一个具体而琐碎的问题:找出并清掉磁盘上那些"看着占地方、其实什么都没有"的空目录。相比在资源管理器里逐个翻找,或者写一段一次性的命令行脚本,它把三件事合在了一起:

  • 可视化扫描:结果实时滑入列表,扫了多少目录、找到多少个,状态栏一目了然
  • 安全删除:不是简单地 rmdir 一把梭,而是有确认、有复检、有保护、有级联
  • 顺手的日常操作:右键直接打开目录或复制路径,结果可导出成 txt 存档

界面走的是液态玻璃风格:深色底板上三层半透明渐变色块缓慢漂移,按钮悬停时有液体从底部涌起、液面还带波纹,进度条是一段在轨道里来回游动的高亮。所有动画都由一个 33 毫秒的定时器驱动,不额外吃线程。

核心特性

扫描

能力说明
递归遍历从指定根目录向下扫描全部层级
后台执行扫描跑在独立线程,通过队列回传结果,界面全程不卡
实时反馈状态栏显示"已遍历 N 个目录,发现 M 个空文件夹"
随时中断扫描过程中按钮变为"点击停止扫描",再点一次即停
跳过系统目录$RECYCLE.BIN、System Volume Information 不深入、不计入结果,匹配不区分大小写
虚拟列表只渲染可视区域内的行,结果上万条也一样流畅

判空使用的是剪枝前的真实目录内容,所以"里面只装了一个回收站"的目录不会被误判为空——这一点直接决定了它能不能安全地交给删除功能使用。

一键清理

删除是这个工具最需要谨慎的部分,因此做了五层防护:

  1. 二次确认:弹窗写明将删除的数量、扫描根目录和全部安全规则,默认焦点停在"否"
  2. 由深及浅:按路径深度降序删除,先删子目录再删父目录,不会留下孤儿
  3. 删前复检:每个目录删除前重新 listdir() 确认仍为空,再交给 os.rmdir()——它本身就拒绝非空目录,等于双保险。扫描之后才新增文件的目录会被跳过
  4. 级联清理:子目录删完后若父目录随之变空,继续向上清理,遇到非空父目录立即停止
  5. 范围硬保护:扫描根目录本身永不删除;不在根目录范围内的路径一律不动(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。

快速上手

  1. 在路径框填入或直接粘贴要扫描的目录,也可以点「浏览…」选择
  2. 点「开始扫描」,等待列表滚动结束,状态栏提示扫描完成
  3. 右键任意一条结果可以直接打开所在目录或复制路径,确认它确实该删
  4. 点「删除全部空文件夹」,在确认框里核对数量和根目录,选「是」
  5. 需要留档就点「导出结果到文件」

提醒:删除操作不可恢复。工具已经尽力做了复检和范围保护,但正式清理重要目录前,先导出结果看一遍仍然值得。


源代码

# -*- 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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--