Files
Directory-Scanner/dir_scanner_gui.py

1484 lines
59 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())