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"""
+
+
+
+
+
+ 目录扫描报告 - 结构可视化
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔍
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+ 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