From 78dbc3212248c00500a4b0a9be65572d71ba0cd6 Mon Sep 17 00:00:00 2001 From: Hsdi Date: Mon, 16 Mar 2026 04:00:58 +0800 Subject: [PATCH] Add logo image file in PNG format --- README.md | 69 ++- dir_scanner_gui.py | 1483 ++++++++++++++++++++++++++++++++++++++++++++ logo.png | Bin 0 -> 7304 bytes 3 files changed, 1551 insertions(+), 1 deletion(-) create mode 100644 dir_scanner_gui.py create mode 100644 logo.png 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 0000000000000000000000000000000000000000..0ca00cccc0ede4ad32128f3da38df704e1062ad0 GIT binary patch literal 7304 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 literal 0 HcmV?d00001