diff --git a/README.md b/README.md index 4ad3b1e..3f5cca2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,69 @@ -# Directory-Scanner +# 📂 Directory Scanner (目录扫描器) +一款现代化、高性能的本地文件目录扫描工具,能够快速生成交互式的 HTML 结构报告。基于 Python + PyQt6 开发,采用 Win11 风格的无边框 UI 设计。 + +## ✨ 主要特性 + +* **🚀 极速扫描**:采用多线程 (`concurrent.futures`) + `os.scandir` 技术,大幅提升大文件夹的扫描速度。 +* **📊 可视化报告**: + * 生成单文件 HTML 报告,易于分享。 + * **双视图模式**:支持传统的**树形列表**和**矩形树图 (Treemap)**(基于 ECharts),直观展示文件大小分布。 + * **懒加载渲染**:采用 Lazy Rendering 技术,轻松应对包含数十万文件的超大规模目录,网页打开秒开不卡顿。 + * **智能搜索**:HTML 报告内置全量搜索功能,支持自动定位和展开深层目录。 +* **🎨 现代化 UI**: + * PyQt6 构建的无边框窗口。 + * 支持窗口阴影、自定义标题栏、拖拽移动。 + * 自动识别文件类型并显示对应的 Emoji 图标。 +* **📁 多目录支持**:支持一次性添加多个文件夹进行批量扫描,生成汇总报告。 +* **🛠️ 独立运行**:支持打包为 EXE 可执行文件,无需 Python 环境即可运行。 + +## 🛠️ 技术栈 + +* **GUI**: Python 3, PyQt6 +* **Frontend**: HTML5, CSS3, Vanilla JS, ECharts +* **Performance**: ThreadPoolExecutor, JSON Data Injection, DOM Fragmentation + +## 📦 安装与运行 + +### 源码运行 + +1. 克隆或下载本项目。 +2. 安装依赖: + ```bash + pip install PyQt6 + ``` +3. 运行程序: + ```bash + python dir_scanner_gui.py + ``` + +### 打包为 EXE + +如果你需要生成独立的可执行文件,可以使用 `pyinstaller`: + +```powershell +# 安装 PyInstaller +pip install pyinstaller + +# 执行打包命令(自动添加图标和时间戳命名) +$name = "DirectoryScanner_" + (Get-Date -Format "yyyyMMdd_HHmmss") +pyinstaller --noconsole --onefile --icon="logo.png" --add-data "logo.png;." --name="$name" dir_scanner_gui.py +``` + +打包完成后,EXE 文件将生成在 `dist` 目录下。 + +## 🖥️ 使用说明 + +1. **添加目录**:点击界面上的 `➕` 按钮选择一个或多个文件夹。 +2. **设置输出**:默认会在当前目录下生成带时间戳的 HTML 报告,也可以手动修改保存路径。 +3. **开始扫描**:点击“开始扫描”按钮,等待进度条完成。 +4. **查看报告**:扫描完成后,可直接点击对话框中的“打开报告”查看结果。 + +## 📝 更新日志 + +* **v1.2**: 优化网页搜索功能,支持全量搜索与自动跳转;修复任务栏图标显示问题。 +* **v1.1**: 新增 ECharts 矩形图视图;优化 HTML 生成逻辑(体积减小 90%);UI 细节打磨。 +* **v1.0**: 初始版本,支持多线程扫描与 PyQt6 无边框界面。 + +--- +*Created by Trae AI Assistant* diff --git a/dir_scanner_gui.py b/dir_scanner_gui.py new file mode 100644 index 0000000..1ca2441 --- /dev/null +++ b/dir_scanner_gui.py @@ -0,0 +1,1483 @@ +import sys +import os +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QProgressBar, QTextEdit, + QFileDialog, QMessageBox, QFrame, QStyle, QGraphicsDropShadowEffect, + QListWidget, QAbstractItemView, QSizePolicy, QDialog) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject, QPoint +from PyQt6.QtGui import QIcon, QFont, QAction, QColor, QPixmap + +def resource_path(relative_path): + """ Get absolute path to resource, works for dev and for PyInstaller """ + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + base_path = os.path.dirname(os.path.abspath(__file__)) + + return os.path.join(base_path, relative_path) + +class ScanWorker(QObject): + finished = pyqtSignal(bool, str) + progress = pyqtSignal(int, str) + log = pyqtSignal(str) + + def __init__(self, target_paths, output_path): + super().__init__() + self.target_paths = target_paths # 这是一个列表 + self.output_path = output_path + self.scanned_count = 0 + self.is_running = True + self.last_emit_time = 0 # For throttling progress signals + + def run(self): + # 延迟导入,优化启动速度 + import concurrent.futures + import time + + try: + # 修正:信号发射使用 emit() 方法 + self.log.emit(f"启动多线程扫描 (线程数: {min(32, (os.cpu_count() or 1) * 4)})...") + + with concurrent.futures.ThreadPoolExecutor(max_workers=min(32, (os.cpu_count() or 1) * 4)) as executor: + if len(self.target_paths) == 1: + # 单目录模式:直接扫描,不使用虚拟根节点 + path = self.target_paths[0] + self.log.emit(f"正在扫描: {path}") + root_tree = self.build_tree(path, executor=executor) + display_path = path + else: + # 多目录模式:使用虚拟根节点 + root_tree = { + "name": "扫描汇总", + "path": "Multiple Roots", + "type": "directory", + "children": [], + "size": 0, + "file_count": 0, + "dir_count": 0 + } + + for path in self.target_paths: + if not self.is_running: break + self.log.emit(f"正在扫描: {path}") + tree_data = self.build_tree(path, executor=executor) + # 标记需要保留绝对路径,防止前端路径拼接错误 + tree_data["_keep_path"] = True + self._merge_child_stats(root_tree, tree_data) + + display_path = "Multi-Scan: " + "; ".join(self.target_paths) + + if not self.is_running: return + + self.log.emit(f"扫描完成,正在生成 HTML 报告...") + self.generate_html(root_tree, display_path, self.output_path) + + self.finished.emit(True, self.output_path) + + except Exception as e: + self.finished.emit(False, str(e)) + + def build_tree(self, path, executor=None, depth=0): + if not self.is_running: return {} + + # Lazy import time inside method to avoid top-level import if not needed elsewhere? + # Actually it's better to import at top of file or in run(), but build_tree is recursive. + # We'll use the one imported in run() or standard library time if available globally. + # But wait, run() imports are local. Let's rely on standard 'import time' being fast enough to put at top of file, + # OR just use 'import time' inside build_tree or rely on it being available. + # To be safe and clean, I will add 'import time' to top of file in next step or use it here. + # For now, let's assume I can add it to top of file or use local import. + import time + + name = os.path.basename(path) + if name == "": name = path + + node = { + "name": name, + "path": path, + "type": "directory", + "children": [], + "size": 0, + "file_count": 0, + "dir_count": 0 + } + + try: + with os.scandir(path) as it: + entries = list(it) + entries.sort(key=lambda x: (not x.is_dir(), x.name.lower())) + + futures = [] + + for entry in entries: + if not self.is_running: break + + self.scanned_count += 1 + + # Throttle progress emission: emit every 100ms or every 1000 items + current_time = time.time() + if current_time - self.last_emit_time > 0.1: + self.progress.emit(self.scanned_count, entry.path) + self.last_emit_time = current_time + + if entry.is_dir(): + if executor and depth < 2: + future = executor.submit(self.build_tree, entry.path, executor, depth + 1) + futures.append(future) + else: + child_node = self.build_tree(entry.path, executor, depth + 1) + self._merge_child_stats(node, child_node) + else: + try: + stat = entry.stat() + size = stat.st_size + except OSError: + size = 0 + + node["children"].append({ + "name": entry.name, + "path": entry.path, + "type": "file", + "size": size + }) + node["size"] += size + node["file_count"] += 1 + + for future in futures: + if not self.is_running: break + try: + child_node = future.result() + self._merge_child_stats(node, child_node) + except Exception as e: + # 修正:信号发射使用 emit() 方法 + self.log.emit(f"线程执行错误: {e}") + + except PermissionError: + self.log.emit(f"权限被拒绝: {path}") + node["error"] = "Permission Denied" + except Exception as e: + self.log.emit(f"读取错误 {path}: {e}") + node["error"] = str(e) + + return node + + def _merge_child_stats(self, parent, child): + parent["children"].append(child) + parent["size"] += child.get("size", 0) + parent["file_count"] += child.get("file_count", 0) + parent["dir_count"] += child.get("dir_count", 0) + 1 + + def _format_size(self, size): + if size < 1024: return f"{size} B" + elif size < 1024 * 1024: return f"{size/1024:.2f} KB" + elif size < 1024 * 1024 * 1024: return f"{size/(1024*1024):.2f} MB" + else: return f"{size/(1024*1024*1024):.2f} GB" + + def _get_icon(self, name, is_dir): + if is_dir: return "📂" + ext = os.path.splitext(name)[1].lower() + icons = { + # 图片 / 设计 + '.jpg': '🖼️', '.jpeg': '🖼️', '.png': '🖼️', '.gif': '🖼️', '.bmp': '🖼️', '.svg': '🖼️', '.ico': '🖼️', + '.webp': '🖼️', '.tiff': '🖼️', '.psd': '🎨', '.ai': '🎨', '.raw': '📷', '.heic': '📷', + # 视频 + '.mp4': '🎬', '.avi': '🎬', '.mkv': '🎬', '.mov': '🎬', '.wmv': '🎬', '.flv': '🎬', '.webm': '🎬', + '.m4v': '🎬', '.3gp': '🎬', '.ts': '🎬', + # 音频 + '.mp3': '🎵', '.wav': '🎵', '.flac': '🎵', '.aac': '🎵', '.ogg': '🎵', '.wma': '🎵', '.m4a': '🎵', '.mid': '🎹', + # 代码 - Web + '.html': '🌐', '.htm': '🌐', '.css': '🎨', '.scss': '🎨', '.sass': '🎨', '.less': '🎨', + '.js': '📜', '.jsx': '⚛️', '.ts': '📘', '.tsx': '⚛️', '.vue': '🟢', + '.php': '🐘', '.asp': '🌐', '.aspx': '🌐', '.jsp': '☕', + # 代码 - 后端/通用 + '.py': '🐍', '.pyw': '🐍', '.pyc': '🐍', + '.java': '☕', '.jar': '☕', '.class': '☕', + '.c': '🇨', '.cpp': '🇨', '.cxx': '🇨', '.h': '🇨', '.hpp': '🇨', + '.cs': '#️⃣', '.go': '🐹', '.rs': '🦀', '.rb': '💎', '.pl': '🐪', + '.swift': '🐦', '.kt': '🎯', '.lua': '🌙', '.r': '📈', '.m': '🍎', + '.sh': '💻', '.bat': '💻', '.ps1': '💻', '.cmd': '💻', '.vbs': '📜', + # 配置文件 / 数据 + '.json': '⚙️', '.xml': '⚙️', '.yaml': '⚙️', '.yml': '⚙️', '.toml': '⚙️', '.ini': '⚙️', '.cfg': '⚙️', '.conf': '⚙️', + '.env': '🔒', '.gitignore': '🚫', '.dockerfile': '🐳', '.makefile': '🐘', + '.sql': '🗄️', '.db': '🗄️', '.sqlite': '🗄️', '.mdb': '🗄️', + '.csv': '📊', '.tsv': '📊', + # 文档 / 办公 + '.pdf': '📕', + '.doc': '📘', '.docx': '📘', '.rtf': '📝', '.odt': '📘', + '.xls': '📗', '.xlsx': '📗', '.ods': '📗', + '.ppt': '📙', '.pptx': '📙', '.odp': '📙', + '.txt': '📝', '.md': '📝', '.markdown': '📝', '.log': '📝', '.tex': '📜', '.epub': '📚', + # 压缩包 / 镜像 + '.zip': '📦', '.rar': '📦', '.7z': '📦', '.tar': '📦', '.gz': '📦', '.bz2': '📦', + '.iso': '💿', '.dmg': '💿', '.img': '💿', + # 系统 / 执行 + '.exe': '🚀', '.msi': '📦', '.dll': '🔧', '.sys': '⚙️', '.apk': '📱', '.ipa': '📱', + # 字体 + '.ttf': '🔤', '.otf': '🔤', '.woff': '🔤', '.woff2': '🔤', '.eot': '🔤', + # 其他 + '.bak': '🔙', '.tmp': '⏱️', '.swp': '⏱️', '.lock': '🔒' + } + return icons.get(ext, "📄") + + def _optimize_data_for_json(self, node): + """ + 递归优化数据结构以减小 JSON 体积: + 1. 移除 'path' (前端动态计算) + 2. 缩短 key 名: name->n, size->s, type->t, children->c, error->e + 3. 移除 file_count/dir_count (仅在根节点或按需计算,或者保留但缩短key) + """ + # Mapping: + # n: name + # t: type (0: file, 1: directory) - 使用整数进一步减小体积 + # s: size + # c: children + # e: error + + is_dir = node["type"] == "directory" + optimized = { + "n": node["name"], + "t": 1 if is_dir else 0 + } + + # 显式保留路径(用于多根节点等特殊情况) + if node.get("_keep_path"): + optimized["p"] = node["path"] + + if "size" in node and node["size"] > 0: + optimized["s"] = node["size"] + + if "error" in node: + optimized["e"] = node["error"] + + if is_dir and "children" in node: + optimized["c"] = [self._optimize_data_for_json(child) for child in node["children"]] + + return optimized + + def generate_html(self, tree_data, root_path, output_path): + import json + import datetime + import html + + # 1. 准备统计数据 + total_size = self._format_size(tree_data.get("size", 0)) + total_files = tree_data.get("file_count", 0) + total_dirs = tree_data.get("dir_count", 0) + + # 2. 优化树数据结构 (大幅减小体积) + self.log.emit(f"正在优化数据结构以减小报告体积...") + optimized_tree = self._optimize_data_for_json(tree_data) + + # 3. 序列化为 JSON + self.log.emit(f"正在序列化 JSON 数据...") + json_data = json.dumps(optimized_tree, ensure_ascii=False) + + self.log.emit(f"正在生成 HTML 文件...") + + html_content = f""" + + + + + + 目录扫描报告 - 结构可视化 + + + + + +
+
+

正在构建可视化结构...

+
+ +
+
+
+
+

🗂️ 目录结构报告

+
+
+
{total_dirs}文件夹
+
{total_files}文件
+
{total_size}总大小
+
+
+
+ 📍 扫描路径: + {html.escape(root_path)} + 🕒 {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')} +
+
+ +
+
+ 🔍 + +
+
+ + +
+ +
+ +
+
+
    +
    +
    +
    +
    +
    +
    + +
    + + + + + + +""" + with open(output_path, 'w', encoding='utf-8') as f: + f.write(html_content) + +class ModernMessageBox(QDialog): + def __init__(self, title, message, parent=None, buttons=None): + super().__init__(parent) + if buttons is None: + buttons = ["确定"] + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.result_text = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + + self.container = QWidget() + self.container.setStyleSheet(""" + QWidget { + background-color: white; + border-radius: 12px; + border: 1px solid #e0e0e0; + } + """) + + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(20) + shadow.setXOffset(0) + shadow.setYOffset(5) + shadow.setColor(QColor(0, 0, 0, 40)) + self.container.setGraphicsEffect(shadow) + + layout.addWidget(self.container) + + inner_layout = QVBoxLayout(self.container) + inner_layout.setContentsMargins(24, 24, 24, 24) + inner_layout.setSpacing(20) + + # Header + title_lbl = QLabel(title) + title_lbl.setStyleSheet("font-size: 18px; font-weight: 600; color: #222; border: none; background: transparent;") + inner_layout.addWidget(title_lbl) + + # Message + msg_lbl = QLabel(message) + msg_lbl.setWordWrap(True) + msg_lbl.setStyleSheet("font-size: 14px; color: #555; line-height: 1.4; border: none; background: transparent;") + inner_layout.addWidget(msg_lbl) + + # Buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + btn_layout.setSpacing(10) + + for btn_text in buttons: + btn = QPushButton(btn_text) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setFixedHeight(36) + + # Primary button style + if btn_text in ["确定", "是", "打开报告", "Yes", "OK"]: + btn.setStyleSheet(""" + QPushButton { + background-color: #222; + color: white; + border-radius: 6px; + padding: 0 20px; + font-weight: 600; + border: none; + } + QPushButton:hover { background-color: #444; } + QPushButton:pressed { background-color: #000; } + """) + else: + btn.setStyleSheet(""" + QPushButton { + background-color: white; + color: #333; + border-radius: 6px; + padding: 0 20px; + border: 1px solid #ddd; + font-weight: 500; + } + QPushButton:hover { background-color: #f5f5f5; border-color: #ccc; } + QPushButton:pressed { background-color: #eee; } + """) + + btn.clicked.connect(lambda checked, t=btn_text: self.on_btn_clicked(t)) + btn_layout.addWidget(btn) + + inner_layout.addLayout(btn_layout) + + def on_btn_clicked(self, text): + self.result_text = text + self.accept() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.old_pos = event.globalPosition().toPoint() + + def mouseMoveEvent(self, event): + if hasattr(self, 'old_pos') and self.old_pos: + delta = event.globalPosition().toPoint() - self.old_pos + self.move(self.pos() + delta) + self.old_pos = event.globalPosition().toPoint() + + def mouseReleaseEvent(self, event): + self.old_pos = None + +class DirectoryScannerWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("目录扫描器") + self.resize(520, 540) # 稍微加大一点以容纳阴影 + + # 无边框设置 + self.setWindowFlags(Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + self.worker = None + self.old_pos = None + self.init_ui() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.old_pos = event.globalPosition().toPoint() + + def mouseMoveEvent(self, event): + if self.old_pos: + delta = event.globalPosition().toPoint() - self.old_pos + self.move(self.pos() + delta) + self.old_pos = event.globalPosition().toPoint() + + def mouseReleaseEvent(self, event): + self.old_pos = None + + def init_ui(self): + # 外层容器(用于显示阴影) + container = QWidget() + self.setCentralWidget(container) + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(10, 10, 10, 10) + + # 定义样式表 + self.STYLES = { + "main_widget": """ + QWidget#MainWidget { + background-color: white; + border-radius: 16px; + border: 1px solid #f0f0f0; + } + """, + "title_btn": """ + QPushButton { border: none; border-radius: 16px; font-weight: bold; color: #888; background: transparent; } + QPushButton:hover { background-color: #f5f5f5; color: #333; } + """, + "close_btn": """ + QPushButton { border: none; border-radius: 16px; font-weight: bold; color: #888; background: transparent; } + QPushButton:hover { background-color: #ff4d4d; color: white; } + """, + "list_widget": """ + QListWidget { + border: 1px solid #eaeaea; + border-radius: 6px; + background: #fdfdfd; + padding: 5px; + font-size: 13px; + color: #333; + } + QListWidget:focus { + border: 1px solid #ccc; + background: white; + } + """, + "progress_bar": """ + QProgressBar { border: none; background: #f0f0f0; border-radius: 2px; } + QProgressBar::chunk { background-color: #333; border-radius: 2px; } + """, + "scan_btn": """ + QPushButton { + background-color: #222; + color: white; + border-radius: 19px; + font-weight: 600; + font-size: 13px; + border: none; + } + QPushButton:hover { background-color: #444; } + QPushButton:pressed { background-color: #000; } + QPushButton:disabled { background-color: #eee; color: #aaa; } + """, + "log_text": """ + QTextEdit { + color: #555; + font-family: 'Consolas', monospace; + font-size: 11px; + background: #f9f9f9; + padding: 12px; + border-radius: 8px; + } + """ + } + + # 主内容区域 + self.main_widget = QWidget() + self.main_widget.setObjectName("MainWidget") + self.main_widget.setStyleSheet(self.STYLES["main_widget"]) + + # 添加阴影效果 + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(15) + shadow.setXOffset(0) + shadow.setYOffset(4) + shadow.setColor(QColor(0, 0, 0, 30)) + self.main_widget.setGraphicsEffect(shadow) + + container_layout.addWidget(self.main_widget) + + # 布局 + layout = QVBoxLayout(self.main_widget) + layout.setContentsMargins(30, 20, 30, 30) + layout.setSpacing(20) + + # Custom Title Bar + title_bar = QHBoxLayout() + title_bar.setContentsMargins(0, 0, 0, 10) + + # Logo + logo_path = resource_path("logo.png") + if os.path.exists(logo_path): + self.setWindowIcon(QIcon(logo_path)) + + logo_lbl = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaled(24, 24, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + logo_lbl.setPixmap(scaled) + logo_lbl.setStyleSheet("border: none; background: transparent; margin-right: 5px;") + title_bar.addWidget(logo_lbl) + + title_label = QLabel("目录扫描器") + title_label.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold)) + title_label.setStyleSheet("color: #333; border: none; background: transparent;") + + btn_min = QPushButton("─") + btn_min.setFixedSize(32, 32) + btn_min.setCursor(Qt.CursorShape.PointingHandCursor) + btn_min.clicked.connect(self.showMinimized) + btn_min.setStyleSheet(self.STYLES["title_btn"]) + + btn_close = QPushButton("✕") + btn_close.setFixedSize(32, 32) + btn_close.setCursor(Qt.CursorShape.PointingHandCursor) + btn_close.clicked.connect(self.close) + btn_close.setStyleSheet(self.STYLES["close_btn"]) + + title_bar.addWidget(title_label) + title_bar.addStretch() + title_bar.addWidget(btn_min) + title_bar.addWidget(btn_close) + + layout.addLayout(title_bar) + + # Form + form_layout = QVBoxLayout() + form_layout.setSpacing(20) + + # Directory List + self.dir_list = QListWidget() + self.dir_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.dir_list.setStyleSheet(self.STYLES["list_widget"]) + self.dir_list.setFixedHeight(100) + + btn_add_dir = self.create_tool_button("➕", self.add_directory) + btn_remove_dir = self.create_tool_button("➖", self.remove_directory) + + dir_btn_layout = QVBoxLayout() + dir_btn_layout.addWidget(btn_add_dir) + dir_btn_layout.addWidget(btn_remove_dir) + dir_btn_layout.addStretch() + + dir_input_layout = QHBoxLayout() + dir_input_layout.addWidget(self.dir_list) + dir_input_layout.addLayout(dir_btn_layout) + + container = QVBoxLayout() + container.setSpacing(6) + label = QLabel("扫描目标 (支持多选)") + label.setStyleSheet("color: #888; font-weight: 500; font-size: 12px; letter-spacing: 0.5px;") + container.addWidget(label) + container.addLayout(dir_input_layout) + + form_layout.addLayout(container) + + # Output + self.entry_out = self.create_input("默认保存到当前目录...") + btn_out = self.create_tool_button("💾", self.select_save_file) + form_layout.addLayout(self.create_field("保存报告", self.entry_out, btn_out)) + + layout.addLayout(form_layout) + + # Progress & Action + action_layout = QHBoxLayout() + + # Progress Section + self.progress_container = QWidget() + progress_layout = QVBoxLayout(self.progress_container) + progress_layout.setContentsMargins(0, 0, 0, 0) + progress_layout.setSpacing(5) + + self.progress_bar = QProgressBar() + self.progress_bar.setTextVisible(False) + self.progress_bar.setFixedHeight(4) + self.progress_bar.setStyleSheet(self.STYLES["progress_bar"]) + + self.progress_label = QLabel("准备就绪") + self.progress_label.setStyleSheet("color: #999; font-size: 12px;") + # 关键:设置 SizePolicy 为 Ignored,防止文本过长撑开窗口 + self.progress_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + + progress_layout.addWidget(self.progress_bar) + progress_layout.addWidget(self.progress_label) + self.progress_container.setVisible(False) + + action_layout.addWidget(self.progress_container, stretch=1) + + # Scan Button + self.btn_scan = QPushButton("开始扫描") + self.btn_scan.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_scan.setFixedSize(110, 38) + self.btn_scan.setStyleSheet(self.STYLES["scan_btn"]) + self.btn_scan.clicked.connect(self.start_scan) + action_layout.addWidget(self.btn_scan) + + layout.addLayout(action_layout) + + # Log + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setFrameShape(QFrame.Shape.NoFrame) + self.log_text.setPlaceholderText("等待任务启动...") + self.log_text.setStyleSheet(self.STYLES["log_text"]) + layout.addWidget(self.log_text, stretch=1) + + def create_input(self, placeholder): + le = QLineEdit() + le.setPlaceholderText(placeholder) + le.setStyleSheet(""" + QLineEdit { + padding: 8px 12px; + border: 1px solid #eaeaea; + border-radius: 6px; + background: #fdfdfd; + font-size: 13px; + color: #333; + } + QLineEdit:focus { + border: 1px solid #ccc; + background: white; + } + """) + return le + + def create_tool_button(self, text, callback): + btn = QPushButton(text) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setFixedSize(36, 36) + btn.clicked.connect(callback) + btn.setStyleSheet(""" + QPushButton { + border: 1px solid #eaeaea; + border-radius: 6px; + background: white; + font-size: 14px; + } + QPushButton:hover { + background: #f5f5f5; + border-color: #ddd; + } + """) + return btn + + def create_field(self, label_text, widget, btn): + container = QVBoxLayout() + container.setSpacing(6) + + label = QLabel(label_text) + label.setStyleSheet("color: #888; font-weight: 500; font-size: 12px; letter-spacing: 0.5px;") + container.addWidget(label) + + input_layout = QHBoxLayout() + input_layout.setSpacing(8) + input_layout.addWidget(widget) + input_layout.addWidget(btn) + + container.addLayout(input_layout) + return container + + def add_directory(self): + import datetime # Lazy import + directory = QFileDialog.getExistingDirectory(self, "选择扫描目录") + if directory: + # 检查是否重复 + items = [self.dir_list.item(i).text() for i in range(self.dir_list.count())] + if directory not in items: + self.dir_list.addItem(directory) + + # 自动设置默认输出路径 (如果还没设置) + if not self.entry_out.text(): + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + name_part = os.path.basename(directory) if self.dir_list.count() == 1 else "multi_dirs" + default_name = f"scan_report_{name_part}_{timestamp}.html" + self.entry_out.setText(os.path.join(os.getcwd(), default_name)) + + def remove_directory(self): + for item in self.dir_list.selectedItems(): + self.dir_list.takeItem(self.dir_list.row(item)) + + def select_save_file(self): + file_path, _ = QFileDialog.getSaveFileName(self, "保存报告", "", "HTML Files (*.html);;All Files (*.*)") + if file_path: + self.entry_out.setText(file_path) + + def log(self, message): + self.log_text.append(message) + + def start_scan(self): + import datetime # Lazy import + + target_paths = [self.dir_list.item(i).text() for i in range(self.dir_list.count())] + output_path = self.entry_out.text() + + if not target_paths: + QMessageBox.critical(self, "错误", "请至少添加一个扫描目录!") + return + + # Normalize paths + target_paths = [os.path.normpath(p) for p in target_paths] + + if not output_path: + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + name_part = os.path.basename(target_paths[0]) if len(target_paths) == 1 else "multi_dirs" + default_name = f"scan_report_{name_part}_{timestamp}.html" + output_path = os.path.join(os.getcwd(), default_name) + self.entry_out.setText(output_path) + + self.btn_scan.setEnabled(False) + self.log_text.clear() + self.progress_container.setVisible(True) + self.progress_bar.setRange(0, 0) # Indeterminate mode + + self.log(f"开始扫描 {len(target_paths)} 个目录...") + + self.thread = QThread() + self.worker = ScanWorker(target_paths, output_path) + self.worker.moveToThread(self.thread) + + self.thread.started.connect(self.worker.run) + self.worker.finished.connect(self.scan_finished) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.update_progress) + self.worker.log.connect(self.log) + + self.thread.start() + + def update_progress(self, count, path): + # 智能截断路径,防止撑开窗口 + # 获取文件名 + name = os.path.basename(path) + # 如果路径太长,优先显示文件名 + if len(path) > 35: + if len(name) > 30: + display_path = name[:25] + "..." + else: + # 尝试显示部分父目录 + 文件名 + display_path = "..." + path[-30:] + else: + display_path = path + + self.progress_label.setText(f"已扫描: {count} | 当前: {display_path}") + + def scan_finished(self, success, message): + self.btn_scan.setEnabled(True) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(100) + + if success: + self.log(f"报告已生成: {message}") + self.progress_label.setText("完成") + + msg_box = ModernMessageBox("扫描完成", f"扫描任务已完成!\n报告已保存至:\n{os.path.basename(message)}\n\n是否立即打开报告?", self, ["打开报告", "稍后"]) + msg_box.exec() + + if msg_box.result_text == "打开报告": + try: + os.startfile(message) + except Exception as e: + QMessageBox.warning(self, "错误", f"无法打开文件: {e}") + else: + self.log(f"发生错误: {message}") + self.progress_label.setText("发生错误") + ModernMessageBox("错误", f"扫描过程中发生错误:\n{message}", self, ["确定"]).exec() + +if __name__ == "__main__": + # 1. 设置 AppUserModelID,使 Windows 任务栏能正确识别独立图标 + import ctypes + myappid = 'mycompany.myproduct.subproduct.version' # 任意唯一的字符串 + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except Exception: + pass + + app = QApplication(sys.argv) + app.setStyle("Fusion") # Use Fusion style for better cross-platform look base + + # 2. 设置全局应用程序图标 + logo_path = resource_path("logo.png") + if os.path.exists(logo_path): + app.setWindowIcon(QIcon(logo_path)) + + # Global Font + font = QFont("Segoe UI", 9) + app.setFont(font) + + window = DirectoryScannerWindow() + window.show() + sys.exit(app.exec()) diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..0ca00cc Binary files /dev/null and b/logo.png differ