1484 lines
59 KiB
Python
1484 lines
59 KiB
Python
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"""
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>目录扫描报告 - 结构可视化</title>
|
||
<!-- 引入 ECharts -->
|
||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||
<style>
|
||
:root {{
|
||
--primary: #2563eb;
|
||
--bg: #f8fafc;
|
||
--card: #ffffff;
|
||
--text: #1e293b;
|
||
--text-light: #64748b;
|
||
--border: #e2e8f0;
|
||
--hover: #f1f5f9;
|
||
--line-color: #cbd5e1;
|
||
}}
|
||
|
||
* {{ box-sizing: border-box; }}
|
||
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background-color: var(--bg);
|
||
color: var(--text);
|
||
margin: 0;
|
||
padding: 20px;
|
||
line-height: 1.6;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}}
|
||
|
||
.container {{
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
height: 100%;
|
||
}}
|
||
|
||
/* Header Card */
|
||
.header {{
|
||
background: var(--card);
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||
padding: 20px;
|
||
margin-bottom: 16px;
|
||
flex-shrink: 0;
|
||
}}
|
||
|
||
.header-top {{
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}}
|
||
|
||
.title-group h1 {{ margin: 0; font-size: 1.25rem; font-weight: 700; color: #0f172a; display: flex; align-items: center; gap: 10px; }}
|
||
.meta-info {{ color: var(--text-light); font-size: 0.875rem; display: flex; gap: 16px; align-items: center; margin-top: 8px; }}
|
||
.path-badge {{ background: #eff6ff; color: var(--primary); padding: 4px 10px; border-radius: 6px; font-family: monospace; font-size: 0.8rem; word-break: break-all; }}
|
||
|
||
.stats-row {{ display: flex; gap: 30px; margin-left: 40px; }}
|
||
.stat {{ text-align: center; }}
|
||
.stat-val {{ display: block; font-size: 1.1rem; font-weight: 700; color: #0f172a; }}
|
||
.stat-lbl {{ font-size: 0.75rem; color: var(--text-light); }}
|
||
|
||
/* Controls */
|
||
.controls {{
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
flex-shrink: 0;
|
||
}}
|
||
|
||
.search-wrapper {{
|
||
flex: 1;
|
||
position: relative;
|
||
}}
|
||
|
||
#search-input {{
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
padding-left: 40px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--card);
|
||
font-size: 0.9rem;
|
||
transition: all 0.2s;
|
||
}}
|
||
|
||
#search-input:focus {{ outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }}
|
||
|
||
.search-icon {{
|
||
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||
color: var(--text-light); pointer-events: none;
|
||
}}
|
||
|
||
.btn-group {{ display: flex; background: #e2e8f0; padding: 4px; border-radius: 8px; gap: 2px; }}
|
||
.view-btn {{
|
||
padding: 6px 16px; border: none; background: transparent; border-radius: 6px;
|
||
color: var(--text-light); font-weight: 600; font-size: 0.85rem; cursor: pointer;
|
||
transition: all 0.2s;
|
||
}}
|
||
.view-btn.active {{ background: white; color: var(--primary); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }}
|
||
|
||
/* Content Area */
|
||
.content-area {{
|
||
flex: 1;
|
||
background: var(--card);
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||
overflow: hidden; /* Important for scroll */
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}}
|
||
|
||
/* Tree View */
|
||
#tree-view {{
|
||
overflow: auto;
|
||
padding: 20px;
|
||
height: 100%;
|
||
}}
|
||
|
||
/* Tree Lines CSS */
|
||
ul.tree {{ list-style: none; margin: 0; padding: 0; }}
|
||
ul.tree ul {{ margin-left: 22px; padding-left: 0; }}
|
||
|
||
ul.tree li {{
|
||
position: relative;
|
||
padding-left: 24px;
|
||
}}
|
||
|
||
/* 竖线 */
|
||
ul.tree li::before {{
|
||
content: ""; position: absolute; top: 0; left: 0;
|
||
border-left: 1px solid var(--line-color);
|
||
height: 100%; width: 1px;
|
||
}}
|
||
/* 横线 */
|
||
ul.tree li::after {{
|
||
content: ""; position: absolute; top: 19px; left: 0;
|
||
border-top: 1px solid var(--line-color);
|
||
width: 18px; height: 1px;
|
||
}}
|
||
/* 最后一个子节点,竖线只到横线位置 */
|
||
ul.tree li.last-child::before {{ height: 19px; }}
|
||
|
||
/* 根节点不需要线条 */
|
||
ul.tree > li {{ padding-left: 0; }}
|
||
ul.tree > li::before, ul.tree > li::after {{ display: none; }}
|
||
|
||
.node-row {{
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
user-select: none;
|
||
position: relative;
|
||
z-index: 2; /* Cover lines */
|
||
background: var(--card); /* Hide lines behind text */
|
||
}}
|
||
|
||
.node-row:hover {{ background-color: var(--hover); }}
|
||
.node-row.search-match {{ background-color: #fffbeb; }}
|
||
.node-row.search-current {{ background-color: #fef3c7; box-shadow: inset 4px 0 0 #f59e0b; }}
|
||
|
||
.toggle {{
|
||
width: 20px; height: 20px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
margin-right: 6px; border-radius: 4px;
|
||
color: var(--text-light); transition: all 0.2s;
|
||
font-family: monospace; font-size: 1.1em;
|
||
background: rgba(0,0,0,0.03);
|
||
}}
|
||
.toggle:hover {{ background: #cbd5e1; color: var(--text); }}
|
||
.toggle.expanded {{ transform: rotate(90deg); background: var(--primary); color: white; }}
|
||
.toggle.disabled {{ opacity: 0; pointer-events: none; }}
|
||
|
||
.icon {{ margin-right: 8px; font-size: 1.1rem; }}
|
||
.name {{ flex: 1; font-weight: 500; color: #334155; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
|
||
.folder-name {{ color: #0f172a; font-weight: 600; }}
|
||
.meta {{ font-size: 0.75rem; color: #94a3b8; font-family: monospace; margin-left: 10px; }}
|
||
|
||
.hidden {{ display: none !important; }}
|
||
|
||
/* Chart View */
|
||
#chart-view {{
|
||
height: 100%;
|
||
width: 100%;
|
||
display: none;
|
||
padding: 10px;
|
||
}}
|
||
|
||
#main-chart {{
|
||
width: 100%;
|
||
height: 100%;
|
||
}}
|
||
|
||
/* Loading */
|
||
#loading-overlay {{
|
||
position: fixed; top:0; left:0; right:0; bottom:0;
|
||
background: rgba(255,255,255,0.95);
|
||
z-index: 2000;
|
||
display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
transition: opacity 0.5s;
|
||
}}
|
||
.spinner {{
|
||
width: 40px; height: 40px;
|
||
border: 4px solid #e2e8f0; border-top-color: var(--primary);
|
||
border-radius: 50%; animation: spin 1s linear infinite;
|
||
}}
|
||
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
|
||
|
||
/* Toast */
|
||
#toast {{
|
||
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||
background: #1e293b; color: white; padding: 10px 20px; border-radius: 50px;
|
||
font-size: 0.85rem; opacity: 0; visibility: hidden;
|
||
transition: all 0.3s; z-index: 2100;
|
||
}}
|
||
#toast.show {{ opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="loading-overlay">
|
||
<div class="spinner"></div>
|
||
<p style="margin-top: 15px; color: #64748b;">正在构建可视化结构...</p>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div class="header">
|
||
<div class="header-top">
|
||
<div class="title-group">
|
||
<h1>🗂️ 目录结构报告</h1>
|
||
</div>
|
||
<div class="stats-row">
|
||
<div class="stat"><span class="stat-val">{total_dirs}</span><span class="stat-lbl">文件夹</span></div>
|
||
<div class="stat"><span class="stat-val">{total_files}</span><span class="stat-lbl">文件</span></div>
|
||
<div class="stat"><span class="stat-val">{total_size}</span><span class="stat-lbl">总大小</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="meta-info">
|
||
<span>📍 扫描路径:</span>
|
||
<span class="path-badge" title="{html.escape(root_path)}">{html.escape(root_path)}</span>
|
||
<span style="margin-left: auto;">🕒 {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<div class="search-wrapper">
|
||
<span class="search-icon">🔍</span>
|
||
<input type="text" id="search-input" placeholder="搜索文件名...">
|
||
</div>
|
||
<div class="btn-group">
|
||
<button class="view-btn active" onclick="switchView('list')" id="btn-list">📜 列表树</button>
|
||
<button class="view-btn" onclick="switchView('chart')" id="btn-chart">📊 矩形图</button>
|
||
</div>
|
||
<button class="view-btn" style="background:white; border:1px solid #e2e8f0;" id="btn-collapse">📂 全部折叠</button>
|
||
</div>
|
||
|
||
<div class="content-area">
|
||
<div id="tree-view">
|
||
<ul class="tree" id="tree-root"></ul>
|
||
</div>
|
||
<div id="chart-view">
|
||
<div id="main-chart"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script id="tree-data" type="application/json">
|
||
{json_data}
|
||
</script>
|
||
|
||
<script>
|
||
const ROOT_PATH = {json.dumps(root_path)};
|
||
const ICONS = {{
|
||
'image': '🖼️', 'video': '🎬', 'audio': '🎵', 'code': '📜', 'web': '🌐',
|
||
'doc': '📘', 'sheet': '📗', 'slide': '📙', 'zip': '📦', 'exec': '🚀', 'font': '🔤', 'default': '📄', 'folder': '📂'
|
||
}};
|
||
const EXT_MAP = {{
|
||
'.jpg':'image','.jpeg':'image','.png':'image','.gif':'image','.svg':'image','.webp':'image',
|
||
'.mp4':'video','.avi':'video','.mkv':'video','.mov':'video',
|
||
'.mp3':'audio','.wav':'audio','.flac':'audio',
|
||
'.html':'web','.css':'web','.js':'code','.ts':'code','.py':'code','.java':'code','.c':'code','.cpp':'code','.json':'code','.xml':'code',
|
||
'.pdf':'doc','.doc':'doc','.docx':'doc','.xls':'sheet','.xlsx':'sheet','.ppt':'slide','.pptx':'slide',
|
||
'.zip':'zip','.rar':'zip','.7z':'zip','.tar':'zip','.gz':'zip',
|
||
'.exe':'exec','.msi':'exec','.apk':'exec','.ttf':'font','.otf':'font'
|
||
}};
|
||
|
||
function getIcon(name, isDir) {{
|
||
if (isDir) return ICONS.folder;
|
||
const ext = name.includes('.') ? name.substring(name.lastIndexOf('.')).toLowerCase() : '';
|
||
return ICONS[EXT_MAP[ext] || 'default'];
|
||
}}
|
||
|
||
function formatSize(size) {{
|
||
if (!size) return '';
|
||
if (size < 1024) return size + ' B';
|
||
if (size < 1048576) return (size/1024).toFixed(2) + ' KB';
|
||
if (size < 1073741824) return (size/1048576).toFixed(2) + ' MB';
|
||
return (size/1073741824).toFixed(2) + ' GB';
|
||
}}
|
||
|
||
function el(tag, className, text) {{
|
||
const e = document.createElement(tag);
|
||
if (className) e.className = className;
|
||
if (text) e.textContent = text;
|
||
return e;
|
||
}}
|
||
|
||
// --- Tree Renderer ---
|
||
class TreeRenderer {{
|
||
constructor(rootData, container) {{
|
||
this.rootData = rootData;
|
||
this.container = container;
|
||
this.pathSeparator = ROOT_PATH.includes('/') ? '/' : '\\\\';
|
||
}}
|
||
|
||
init() {{
|
||
this.renderNode(this.rootData, this.container, ROOT_PATH, true); // Root is always "last" in its container
|
||
}}
|
||
|
||
renderNode(node, parentEl, currentPath, isLast) {{
|
||
const li = el('li');
|
||
if (isLast) li.classList.add('last-child');
|
||
|
||
const isDir = node.t === 1;
|
||
|
||
// Use explicit path if available, otherwise inherit
|
||
const nodePath = node.p || currentPath;
|
||
|
||
// Row
|
||
const row = el('div', 'node-row');
|
||
row.dataset.path = nodePath;
|
||
row.dataset.type = isDir ? 'dir' : 'file';
|
||
|
||
// Toggle
|
||
const toggle = el('span', 'toggle', isDir && node.c && node.c.length ? '+' : '');
|
||
if (!isDir || !node.c || !node.c.length) toggle.classList.add('disabled');
|
||
row.appendChild(toggle);
|
||
|
||
// Icon
|
||
row.appendChild(el('span', 'icon', getIcon(node.n, isDir)));
|
||
|
||
// Name
|
||
row.appendChild(el('span', isDir ? 'name folder-name' : 'name', node.n));
|
||
|
||
// Size
|
||
if (node.s) row.appendChild(el('span', 'meta', formatSize(node.s)));
|
||
|
||
li.appendChild(row);
|
||
|
||
// Children Container (Lazy)
|
||
if (isDir && node.c && node.c.length) {{
|
||
const ul = el('ul', 'hidden');
|
||
li.appendChild(ul);
|
||
row._childrenData = node.c;
|
||
row._childrenUl = ul;
|
||
row._rendered = false;
|
||
}}
|
||
|
||
parentEl.appendChild(li);
|
||
}}
|
||
|
||
expandNode(row) {{
|
||
if (!row._childrenData || !row._childrenUl) return;
|
||
const ul = row._childrenUl;
|
||
const toggle = row.querySelector('.toggle');
|
||
|
||
if (ul.classList.contains('hidden')) {{
|
||
ul.classList.remove('hidden');
|
||
toggle.classList.add('expanded');
|
||
toggle.textContent = '-';
|
||
|
||
if (!row._rendered) {{
|
||
const parentPath = row.dataset.path;
|
||
const fragment = document.createDocumentFragment();
|
||
const len = row._childrenData.length;
|
||
row._childrenData.forEach((child, index) => {{
|
||
// 如果子节点有绝对路径(p),则在 renderNode 中会优先使用
|
||
// 但为了传递给更深层级,我们需要计算一个默认路径
|
||
const childPath = parentPath + (parentPath.endsWith(this.pathSeparator) ? '' : this.pathSeparator) + child.n;
|
||
this.renderNode(child, fragment, childPath, index === len - 1);
|
||
}});
|
||
ul.appendChild(fragment);
|
||
row._rendered = true;
|
||
}}
|
||
}} else {{
|
||
ul.classList.add('hidden');
|
||
toggle.classList.remove('expanded');
|
||
toggle.textContent = '+';
|
||
}}
|
||
}}
|
||
}}
|
||
|
||
// --- Chart Logic ---
|
||
let chartInstance = null;
|
||
let chartDataCache = null;
|
||
|
||
function initChart(rootData) {{
|
||
if (chartInstance) return;
|
||
|
||
const chartDom = document.getElementById('main-chart');
|
||
chartInstance = echarts.init(chartDom);
|
||
|
||
showToast('正在生成图表数据...');
|
||
|
||
// 异步处理以防止UI卡死
|
||
setTimeout(() => {{
|
||
if (!chartDataCache) {{
|
||
chartDataCache = transformDataForChart(rootData, 0);
|
||
}}
|
||
|
||
const option = {{
|
||
title: {{ text: '文件大小分布 (Treemap)', left: 'center', top: 10 }},
|
||
tooltip: {{
|
||
formatter: function (info) {{
|
||
var value = info.value;
|
||
var treePathInfo = info.treePathInfo;
|
||
var treePath = [];
|
||
for (var i = 1; i < treePathInfo.length; i++) {{
|
||
treePath.push(treePathInfo[i].name);
|
||
}}
|
||
return '<div class="tooltip-title">' + echarts.format.encodeHTML(treePath.join('/')) + '</div>' +
|
||
'大小: ' + formatSize(value);
|
||
}}
|
||
}},
|
||
series: [
|
||
{{
|
||
type: 'treemap',
|
||
visibleMin: 300,
|
||
label: {{
|
||
show: true,
|
||
formatter: '{{b}}'
|
||
}},
|
||
itemStyle: {{
|
||
borderColor: '#fff'
|
||
}},
|
||
levels: [
|
||
{{ itemStyle: {{ borderColor: '#555', borderWidth: 2, gapWidth: 2 }}, upperLabel: {{ show: false }} }},
|
||
{{ colorSaturation: [0.3, 0.6], itemStyle: {{ borderColorSaturation: 0.7, gapWidth: 1, borderWidth: 1 }} }},
|
||
{{ colorSaturation: [0.3, 0.5] }}
|
||
],
|
||
data: [chartDataCache]
|
||
}}
|
||
]
|
||
}};
|
||
|
||
chartInstance.setOption(option);
|
||
window.addEventListener('resize', () => chartInstance.resize());
|
||
}}, 100);
|
||
}}
|
||
|
||
function transformDataForChart(node, depth) {{
|
||
// 性能优化:限制深度或忽略极小文件
|
||
if (depth > 4) return null;
|
||
|
||
const item = {{
|
||
name: node.n,
|
||
value: node.s || 0
|
||
}};
|
||
|
||
if (node.t === 1 && node.c) {{
|
||
const children = [];
|
||
let otherSize = 0;
|
||
|
||
node.c.forEach(child => {{
|
||
// 仅展示 > 100KB 的文件或文件夹,减少渲染压力
|
||
if (child.s > 1024 * 100 || child.t === 1) {{
|
||
const cItem = transformDataForChart(child, depth + 1);
|
||
if (cItem) children.push(cItem);
|
||
}} else {{
|
||
otherSize += (child.s || 0);
|
||
}}
|
||
}});
|
||
|
||
if (otherSize > 0) {{
|
||
children.push({{ name: 'Others (<100KB)', value: otherSize, itemStyle: {{ color: '#ccc' }} }});
|
||
}}
|
||
|
||
if (children.length > 0) item.children = children;
|
||
}}
|
||
|
||
return item;
|
||
}}
|
||
|
||
// --- Main ---
|
||
let renderer;
|
||
let treeData;
|
||
|
||
function switchView(mode) {{
|
||
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('btn-' + mode).classList.add('active');
|
||
|
||
if (mode === 'list') {{
|
||
document.getElementById('tree-view').style.display = 'block';
|
||
document.getElementById('chart-view').style.display = 'none';
|
||
}} else {{
|
||
document.getElementById('tree-view').style.display = 'none';
|
||
document.getElementById('chart-view').style.display = 'block';
|
||
initChart(treeData);
|
||
}}
|
||
}}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {{
|
||
try {{
|
||
const rawData = document.getElementById('tree-data').textContent;
|
||
treeData = JSON.parse(rawData);
|
||
renderer = new TreeRenderer(treeData, document.getElementById('tree-root'));
|
||
|
||
setTimeout(() => {{
|
||
renderer.init();
|
||
document.getElementById('loading-overlay').style.opacity = '0';
|
||
setTimeout(() => document.getElementById('loading-overlay').remove(), 500);
|
||
}}, 50);
|
||
|
||
// Events
|
||
document.getElementById('tree-root').addEventListener('click', (e) => {{
|
||
const target = e.target;
|
||
const row = target.closest('.node-row');
|
||
if (!row) return;
|
||
|
||
if (target.classList.contains('toggle') || row.dataset.type === 'dir') {{
|
||
e.stopPropagation();
|
||
renderer.expandNode(row);
|
||
}} else if (row.dataset.type === 'file') {{
|
||
copyToClipboard(row.dataset.path);
|
||
}}
|
||
}});
|
||
|
||
document.getElementById('btn-collapse').addEventListener('click', () => {{
|
||
const rootUl = document.getElementById('tree-root');
|
||
rootUl.querySelectorAll(':scope > li > .node-row > .toggle.expanded').forEach(t => {{
|
||
renderer.expandNode(t.closest('.node-row'));
|
||
}});
|
||
showToast('已折叠根目录');
|
||
}});
|
||
|
||
// Search Implementation
|
||
const searchInput = document.getElementById('search-input');
|
||
let searchResults = [];
|
||
let currentSearchIndex = -1;
|
||
|
||
searchInput.addEventListener('keydown', (e) => {{
|
||
if (e.key === 'Enter') {{
|
||
const term = searchInput.value.trim().toLowerCase();
|
||
if (searchResults.length > 0 && searchResults[0].term === term) {{
|
||
nextMatch();
|
||
}} else {{
|
||
performSearch(term);
|
||
}}
|
||
}}
|
||
}});
|
||
|
||
function performSearch(term) {{
|
||
// Clear previous
|
||
document.querySelectorAll('.node-row.search-match').forEach(el => el.classList.remove('search-match'));
|
||
document.querySelectorAll('.node-row.search-current').forEach(el => el.classList.remove('search-current'));
|
||
|
||
searchResults = [];
|
||
currentSearchIndex = -1;
|
||
|
||
if (!term) return;
|
||
|
||
showToast('正在全量搜索...');
|
||
|
||
const sep = renderer.pathSeparator;
|
||
|
||
// DFS Traversal
|
||
function traverse(node, currentPath, ancestors) {{
|
||
const isMatch = node.n.toLowerCase().includes(term);
|
||
|
||
// Create chain: [...ancestors, currentPath]
|
||
const chain = [...ancestors, currentPath];
|
||
|
||
if (isMatch) {{
|
||
searchResults.push({{ chain, node, term }});
|
||
}}
|
||
|
||
if (node.t === 1 && node.c) {{
|
||
node.c.forEach(child => {{
|
||
let childPath = child.p || (currentPath + (currentPath.endsWith(sep) ? '' : sep) + child.n);
|
||
traverse(child, childPath, chain);
|
||
}});
|
||
}}
|
||
}}
|
||
|
||
// Start search from root
|
||
traverse(treeData, ROOT_PATH, []);
|
||
|
||
if (searchResults.length === 0) {{
|
||
showToast('未找到匹配项');
|
||
return;
|
||
}}
|
||
|
||
showToast(`找到 ${{searchResults.length}} 个匹配项。按 Enter 跳转。`);
|
||
nextMatch();
|
||
}}
|
||
|
||
function nextMatch() {{
|
||
if (searchResults.length === 0) return;
|
||
|
||
// Remove current highlight
|
||
const prev = document.querySelector('.node-row.search-current');
|
||
if (prev) prev.classList.remove('search-current');
|
||
|
||
currentSearchIndex = (currentSearchIndex + 1) % searchResults.length;
|
||
const match = searchResults[currentSearchIndex];
|
||
|
||
revealPath(match.chain);
|
||
showToast(`匹配 ${{currentSearchIndex + 1}} / ${{searchResults.length}}`);
|
||
}}
|
||
|
||
function revealPath(chain) {{
|
||
let currentUl = document.getElementById('tree-root');
|
||
|
||
chain.forEach((path, index) => {{
|
||
let targetRow = null;
|
||
// Find row in current level
|
||
for (let li of currentUl.children) {{
|
||
const row = li.querySelector('.node-row');
|
||
if (row && row.dataset.path === path) {{
|
||
targetRow = row;
|
||
break;
|
||
}}
|
||
}}
|
||
|
||
if (!targetRow) return;
|
||
|
||
const isLast = index === chain.length - 1;
|
||
|
||
if (isLast) {{
|
||
targetRow.classList.add('search-match');
|
||
targetRow.classList.add('search-current');
|
||
targetRow.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
|
||
}} else {{
|
||
const ul = targetRow._childrenUl;
|
||
if (ul && ul.classList.contains('hidden')) {{
|
||
renderer.expandNode(targetRow);
|
||
}}
|
||
currentUl = targetRow._childrenUl;
|
||
}}
|
||
}});
|
||
}}
|
||
|
||
}} catch (e) {{
|
||
console.error(e);
|
||
alert('数据解析失败,文件可能过大');
|
||
}}
|
||
}});
|
||
|
||
function showToast(msg) {{
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = msg;
|
||
toast.classList.add('show');
|
||
setTimeout(() => toast.classList.remove('show'), 2000);
|
||
}}
|
||
|
||
function copyToClipboard(text) {{
|
||
navigator.clipboard.writeText(text)
|
||
.then(() => showToast('路径已复制 ✅'))
|
||
.catch(() => showToast('复制失败 ❌'));
|
||
}}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
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())
|