首次代码提交

This commit is contained in:
2026-06-26 16:54:55 +08:00
parent 76515ac14e
commit 9bbcf5a710
13 changed files with 3109 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Linux 日志删除助手 - GUI 模块
+290
View File
@@ -0,0 +1,290 @@
"""
删除确认对话框
"""
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTextEdit, QCheckBox, QMessageBox
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QColor
from utils import format_size
class DeleteConfirmDialog(QDialog):
"""删除确认对话框"""
def __init__(
self,
paths: list,
total_size: int,
is_dir: bool = False,
parent=None
):
"""
初始化对话框
参数:
paths: 要删除的路径列表
total_size: 总大小(字节)
is_dir: 是否为目录
parent: 父窗口
"""
super().__init__(parent)
self.paths = paths
self.total_size = total_size
self.is_dir = is_dir
self.confirmed = False
self.init_ui()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle("确认删除")
self.setMinimumSize(500, 350)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
# 主布局
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# 警告图标和标题
header_layout = QHBoxLayout()
icon_label = QLabel("⚠️")
font = QFont()
font.setPointSize(36)
icon_label.setFont(font)
header_layout.addWidget(icon_label)
title_layout = QVBoxLayout()
title_label = QLabel("确认删除")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
title_layout.addWidget(title_label)
size_label = QLabel(f"将释放 {format_size(self.total_size)} 空间")
size_label.setStyleSheet("color: green;")
title_layout.addWidget(size_label)
header_layout.addLayout(title_layout)
header_layout.addStretch()
layout.addLayout(header_layout)
# 分隔线
separator = QLabel()
separator.setFrameStyle(QLabel.HLine | QLabel.Sunken)
layout.addWidget(separator)
# 删除内容列表
content_label = QLabel("将删除以下内容:")
layout.addWidget(content_label)
self.content_text = QTextEdit()
self.content_text.setReadOnly(True)
self.content_text.setMaximumHeight(150)
# 显示删除列表
content = ""
for path in self.paths[:50]: # 最多显示 50 条
content += f"{path}\n"
if len(self.paths) > 50:
content += f"\n... 还有 {len(self.paths) - 50} 个项目"
self.content_text.setPlainText(content)
layout.addWidget(self.content_text)
# 警告文本
if self.is_dir:
warning_label = QLabel(
"⚠️ 目录将被递归删除,包含其中的所有文件和子目录!"
)
warning_label.setStyleSheet("color: red; font-weight: bold;")
warning_label.setWordWrap(True)
layout.addWidget(warning_label)
# 确认复选框
self.confirm_checkbox = QCheckBox("我已确认要删除以上内容")
self.confirm_checkbox.stateChanged.connect(self.on_checkbox_changed)
layout.addWidget(self.confirm_checkbox)
# 按钮
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_button = QPushButton("取消")
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton("删除")
self.delete_button.setEnabled(False)
self.delete_button.setStyleSheet(
"QPushButton:enabled { background-color: #e74c3c; color: white; }"
)
self.delete_button.clicked.connect(self.on_delete_clicked)
button_layout.addWidget(self.delete_button)
layout.addLayout(button_layout)
def on_checkbox_changed(self, state):
"""复选框状态改变"""
self.delete_button.setEnabled(state == Qt.Checked)
def on_delete_clicked(self):
"""删除按钮点击"""
if not self.confirm_checkbox.isChecked():
return
# 最终确认
reply = QMessageBox.critical(
self,
"最终确认",
f"确定要删除 {len(self.paths)} 个项目吗?\n"
f"此操作不可撤销!",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.confirmed = True
self.accept()
def is_confirmed(self) -> bool:
"""是否确认删除"""
return self.confirmed
class LogDeleteConfirmDialog(QDialog):
"""系统日志删除确认对话框"""
def __init__(
self,
log_files: list,
total_size: int,
parent=None
):
"""
初始化对话框
参数:
log_files: 日志文件列表
total_size: 总大小(字节)
parent: 父窗口
"""
super().__init__(parent)
self.log_files = log_files
self.total_size = total_size
self.confirmed = False
self.init_ui()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle("清理系统日志")
self.setMinimumSize(500, 400)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
# 主布局
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# 标题
header_layout = QHBoxLayout()
icon_label = QLabel("📋")
font = QFont()
font.setPointSize(36)
icon_label.setFont(font)
header_layout.addWidget(icon_label)
title_layout = QVBoxLayout()
title_label = QLabel("清理系统日志")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
title_layout.addWidget(title_label)
info_label = QLabel(
f"检测到 /var/log 下有 {len(self.log_files)} 个日志文件,\n"
f"共占用 {format_size(self.total_size)} 空间。"
)
title_layout.addWidget(info_label)
header_layout.addLayout(title_layout)
header_layout.addStretch()
layout.addLayout(header_layout)
# 分隔线
separator = QLabel()
separator.setFrameStyle(QLabel.HLine | QLabel.Sunken)
layout.addWidget(separator)
# 日志文件列表
content_label = QLabel("将删除以下日志文件:")
layout.addWidget(content_label)
self.content_text = QTextEdit()
self.content_text.setReadOnly(True)
# 显示日志文件列表
content = ""
for log_file in self.log_files:
name = log_file.name if hasattr(log_file, 'name') else str(log_file)
size = format_size(log_file.size) if hasattr(log_file, 'size') else ""
content += f"{name} ({size})\n"
self.content_text.setPlainText(content)
layout.addWidget(self.content_text)
# 说明文本
note_label = QLabel(
"💡 这些是 /var/log 目录下的日志文件(不包含子目录中的日志)。\n"
"删除后系统会自动创建新的日志文件。"
)
note_label.setWordWrap(True)
note_label.setStyleSheet("color: gray;")
layout.addWidget(note_label)
# 按钮
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_button = QPushButton("取消")
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton("清理日志")
self.delete_button.setStyleSheet(
"QPushButton { background-color: #3498db; color: white; }"
)
self.delete_button.clicked.connect(self.on_delete_clicked)
button_layout.addWidget(self.delete_button)
layout.addLayout(button_layout)
def on_delete_clicked(self):
"""删除按钮点击"""
reply = QMessageBox.question(
self,
"确认清理",
f"确定要清理这 {len(self.log_files)} 个日志文件吗?\n"
f"将释放 {format_size(self.total_size)} 空间。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.confirmed = True
self.accept()
def is_confirmed(self) -> bool:
"""是否确认删除"""
return self.confirmed
+477
View File
@@ -0,0 +1,477 @@
"""
文件浏览器组件
"""
import os
from typing import Optional, List
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTreeView,
QTableView, QHeaderView, QMenu, QAction,
QMessageBox, QApplication, QSplitter, QLabel,
QPushButton, QProgressBar
)
from PyQt5.QtCore import (
Qt, QSortFilterProxyModel, QModelIndex,
QThread, pyqtSignal, QAbstractTableModel
)
from PyQt5.QtGui import QFont, QColor, QIcon, QCursor
from file_scanner import FileScanner, FileItem, ScanProgress
from utils import format_size, safe_delete
class FileTableModel(QAbstractTableModel):
"""文件表格数据模型"""
HEADERS = ["名称", "大小", "修改时间", "权限", "所有者"]
def __init__(self, parent=None):
super().__init__(parent)
self.files: List[FileItem] = []
self.current_path = ""
def rowCount(self, parent=QModelIndex()):
return len(self.files)
def columnCount(self, parent=QModelIndex()):
return len(self.HEADERS)
def data(self, index, role=Qt.DisplayRole):
if not index.isValid() or index.row() >= len(self.files):
return None
file_item = self.files[index.row()]
column = index.column()
if role == Qt.DisplayRole:
if column == 0:
return f"{file_item.icon} {file_item.name}"
elif column == 1:
return file_item.size_formatted
elif column == 2:
return file_item.modified_time_formatted
elif column == 3:
return file_item.permissions
elif column == 4:
return f"{file_item.owner}:{file_item.group}"
elif role == Qt.UserRole:
# 返回原始数据用于排序
if column == 0:
return file_item.name
elif column == 1:
return file_item.size
elif column == 2:
return file_item.modified_time
elif column == 3:
return file_item.permissions
elif column == 4:
return file_item.owner
elif role == Qt.TextAlignmentRole:
if column == 1:
return Qt.AlignRight | Qt.AlignVCenter
elif role == Qt.ForegroundRole:
if column == 1:
if file_item.size > 1024 * 1024 * 1024: # > 1GB
return QColor(255, 0, 0)
elif file_item.size > 100 * 1024 * 1024: # > 100MB
return QColor(255, 165, 0)
return None
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self.HEADERS[section]
return None
def set_files(self, files: List[FileItem]):
"""设置文件列表"""
self.beginResetModel()
self.files = files
self.endResetModel()
def get_file_at(self, row: int) -> Optional[FileItem]:
"""获取指定行的文件信息"""
if 0 <= row < len(self.files):
return self.files[row]
return None
class SortProxyModel(QSortFilterProxyModel):
"""排序代理模型"""
def __init__(self, parent=None):
super().__init__(parent)
self.setSortRole(Qt.UserRole)
def lessThan(self, left, right):
"""自定义排序逻辑"""
left_data = self.sourceModel().data(left, Qt.UserRole)
right_data = self.sourceModel().data(right, Qt.UserRole)
if left_data is None:
return True
if right_data is None:
return False
# 目录始终排在前面
left_item = self.sourceModel().get_file_at(left.row())
right_item = self.sourceModel().get_file_at(right.row())
if left_item and right_item:
if left_item.is_dir != right_item.is_dir:
return left_item.is_dir
try:
return left_data < right_data
except TypeError:
return str(left_data) < str(right_data)
class DirectoryLoadThread(QThread):
"""目录加载线程"""
finished = pyqtSignal(list, str)
error = pyqtSignal(str)
def __init__(self, scanner: FileScanner, path: str):
super().__init__()
self.scanner = scanner
self.path = path
def run(self):
try:
files = self.scanner.list_directory(self.path)
self.finished.emit(files, self.path)
except Exception as e:
self.error.emit(str(e))
class FileBrowser(QWidget):
"""文件浏览器组件"""
# 信号
file_selected = pyqtSignal(FileItem) # 文件被选中
directory_changed = pyqtSignal(str) # 目录改变
files_deleted = pyqtSignal(int) # 文件被删除(释放的空间)
def __init__(self, scanner: FileScanner, sudo_password: Optional[str] = None, parent=None):
super().__init__(parent)
self.scanner = scanner
self.sudo_password = sudo_password
self.current_path = ""
self.path_history = []
self.history_index = -1
self.init_ui()
def init_ui(self):
"""初始化界面"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
# 工具栏
toolbar_layout = QHBoxLayout()
toolbar_layout.setSpacing(5)
# 后退按钮
self.back_button = QPushButton("")
self.back_button.setFixedSize(30, 30)
self.back_button.setToolTip("后退")
self.back_button.clicked.connect(self.go_back)
self.back_button.setEnabled(False)
toolbar_layout.addWidget(self.back_button)
# 前进按钮
self.forward_button = QPushButton("")
self.forward_button.setFixedSize(30, 30)
self.forward_button.setToolTip("前进")
self.forward_button.clicked.connect(self.go_forward)
self.forward_button.setEnabled(False)
toolbar_layout.addWidget(self.forward_button)
# 上级目录按钮
self.up_button = QPushButton("")
self.up_button.setFixedSize(30, 30)
self.up_button.setToolTip("上级目录")
self.up_button.clicked.connect(self.go_up)
toolbar_layout.addWidget(self.up_button)
# 刷新按钮
self.refresh_button = QPushButton("🔄")
self.refresh_button.setFixedSize(30, 30)
self.refresh_button.setToolTip("刷新")
self.refresh_button.clicked.connect(self.refresh)
toolbar_layout.addWidget(self.refresh_button)
# 当前路径显示
self.path_label = QLabel()
self.path_label.setStyleSheet(
"QLabel { background-color: #f0f0f0; padding: 5px; border: 1px solid #ccc; }"
)
toolbar_layout.addWidget(self.path_label, 1)
# 加载状态
self.loading_label = QLabel("")
self.loading_label.setFixedSize(30, 30)
self.loading_label.setAlignment(Qt.AlignCenter)
self.loading_label.hide()
toolbar_layout.addWidget(self.loading_label)
layout.addLayout(toolbar_layout)
# 文件表格
self.table_view = QTableView()
self.table_view.setSelectionBehavior(QTableView.SelectRows)
self.table_view.setSelectionMode(QTableView.ExtendedSelection)
self.table_view.setEditTriggers(QTableView.NoEditTriggers)
self.table_view.setSortingEnabled(True)
self.table_view.setContextMenuPolicy(Qt.CustomContextMenu)
self.table_view.customContextMenuRequested.connect(self.show_context_menu)
self.table_view.doubleClicked.connect(self.on_item_double_clicked)
self.table_view.clicked.connect(self.on_item_clicked)
# 设置数据模型
self.model = FileTableModel()
self.proxy_model = SortProxyModel()
self.proxy_model.setSourceModel(self.model)
self.table_view.setModel(self.proxy_model)
# 设置列宽
header = self.table_view.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
# 默认按大小降序排序
self.table_view.sortByColumn(1, Qt.DescendingOrder)
layout.addWidget(self.table_view)
# 状态栏
status_layout = QHBoxLayout()
self.status_label = QLabel("就绪")
status_layout.addWidget(self.status_label)
status_layout.addStretch()
self.item_count_label = QLabel()
status_layout.addWidget(self.item_count_label)
layout.addLayout(status_layout)
def load_directory(self, path: str):
"""加载目录内容"""
if not os.path.isdir(path):
QMessageBox.warning(self, "错误", f"目录不存在:{path}")
return
self.current_path = path
# 更新路径历史
if self.history_index < len(self.path_history) - 1:
self.path_history = self.path_history[:self.history_index + 1]
if not self.path_history or self.path_history[-1] != path:
self.path_history.append(path)
self.history_index = len(self.path_history) - 1
# 更新UI
self.update_navigation_buttons()
self.path_label.setText(path)
self.loading_label.show()
self.status_label.setText(f"正在加载 {path}...")
# 发送目录改变信号
self.directory_changed.emit(path)
# 在后台线程加载
self.load_thread = DirectoryLoadThread(self.scanner, path)
self.load_thread.finished.connect(self.on_directory_loaded)
self.load_thread.error.connect(self.on_load_error)
self.load_thread.start()
def on_directory_loaded(self, files: List[FileItem], path: str):
"""目录加载完成"""
if path != self.current_path:
return # 已经切换到其他目录
self.loading_label.hide()
self.model.set_files(files)
# 更新状态栏
total_size = sum(f.size for f in files)
dir_count = sum(1 for f in files if f.is_dir)
file_count = len(files) - dir_count
self.status_label.setText(
f"{dir_count} 个目录, {file_count} 个文件, "
f"总计 {format_size(total_size)}"
)
self.item_count_label.setText(f"{len(files)} 个项目")
def on_load_error(self, error_message: str):
"""加载出错"""
self.loading_label.hide()
self.status_label.setText(f"加载失败:{error_message}")
QMessageBox.critical(self, "错误", f"加载目录失败:\n{error_message}")
def on_item_clicked(self, index: QModelIndex):
"""点击项目"""
source_index = self.proxy_model.mapToSource(index)
file_item = self.model.get_file_at(source_index.row())
if file_item:
self.file_selected.emit(file_item)
def on_item_double_clicked(self, index: QModelIndex):
"""双击项目"""
source_index = self.proxy_model.mapToSource(index)
file_item = self.model.get_file_at(source_index.row())
if file_item and file_item.is_dir:
self.load_directory(file_item.path)
def show_context_menu(self, position):
"""显示右键菜单"""
# 获取选中的项目
selected_indexes = self.table_view.selectedIndexes()
if not selected_indexes:
return
# 获取唯一的行号
rows = set()
for index in selected_indexes:
rows.add(index.row())
# 获取文件信息
selected_files = []
for row in rows:
source_index = self.proxy_model.mapToSource(
self.proxy_model.index(row, 0)
)
file_item = self.model.get_file_at(source_index.row())
if file_item:
selected_files.append(file_item)
if not selected_files:
return
# 创建菜单
menu = QMenu(self)
# 删除动作
delete_action = QAction(f"🗑️ 删除 ({len(selected_files)} 个项目)", self)
delete_action.triggered.connect(lambda: self.delete_files(selected_files))
menu.addAction(delete_action)
# 如果是单个目录,添加进入目录动作
if len(selected_files) == 1 and selected_files[0].is_dir:
menu.addSeparator()
enter_action = QAction("📂 进入目录", self)
enter_action.triggered.connect(
lambda: self.load_directory(selected_files[0].path)
)
menu.addAction(enter_action)
# 显示菜单
menu.exec_(QCursor.pos())
def delete_files(self, files: List[FileItem]):
"""删除文件"""
from gui.confirm_dialog import DeleteConfirmDialog
# 计算总大小
total_size = sum(f.size for f in files)
# 检查是否包含目录
has_dir = any(f.is_dir for f in files)
# 显示确认对话框
paths = [f.path for f in files]
dialog = DeleteConfirmDialog(paths, total_size, has_dir, self)
if dialog.exec_() == DeleteConfirmDialog.Accepted and dialog.is_confirmed():
# 执行删除
deleted_size = 0
errors = []
for file_item in files:
success, message = safe_delete(
file_item.path,
use_sudo=True,
sudo_password=self.sudo_password
)
if success:
deleted_size += file_item.size
else:
errors.append(f"{file_item.name}: {message}")
# 显示结果
if errors:
QMessageBox.warning(
self,
"删除完成(有错误)",
f"成功删除 {len(files) - len(errors)} 个项目,\n"
f"释放 {format_size(deleted_size)} 空间。\n\n"
f"以下项目删除失败:\n" + "\n".join(errors[:10])
)
else:
QMessageBox.information(
self,
"删除成功",
f"成功删除 {len(files)} 个项目,\n"
f"释放 {format_size(deleted_size)} 空间。"
)
# 发送删除信号
self.files_deleted.emit(deleted_size)
# 刷新当前目录
self.refresh()
def go_back(self):
"""后退"""
if self.history_index > 0:
self.history_index -= 1
path = self.path_history[self.history_index]
self.load_directory(path)
def go_forward(self):
"""前进"""
if self.history_index < len(self.path_history) - 1:
self.history_index += 1
path = self.path_history[self.history_index]
self.load_directory(path)
def go_up(self):
"""上级目录"""
if self.current_path:
parent_path = os.path.dirname(self.current_path)
if parent_path != self.current_path:
self.load_directory(parent_path)
def refresh(self):
"""刷新当前目录"""
if self.current_path:
self.load_directory(self.current_path)
def update_navigation_buttons(self):
"""更新导航按钮状态"""
self.back_button.setEnabled(self.history_index > 0)
self.forward_button.setEnabled(
self.history_index < len(self.path_history) - 1
)
self.up_button.setEnabled(
self.current_path and self.current_path != '/'
)
def get_current_path(self) -> str:
"""获取当前路径"""
return self.current_path
+373
View File
@@ -0,0 +1,373 @@
"""
主窗口
"""
import os
from typing import Optional
from PyQt5.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QStatusBar, QMessageBox,
QApplication, QSplitter, QFrame
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QFont, QCloseEvent
from disk_manager import DiskManager, Partition
from file_scanner import FileScanner, FileItem, ScanProgress
from utils import format_size, check_sudo_needs_password, verify_sudo_password, safe_delete
from gui.partition_dialog import PartitionDialog
from gui.file_browser import FileBrowser
from gui.confirm_dialog import LogDeleteConfirmDialog
from gui.sudo_dialog import SudoDialog
class DirectorySizeThread(QThread):
"""目录大小统计线程"""
progress = pyqtSignal(object) # ScanProgress
finished = pyqtSignal(dict) # {path: size}
error = pyqtSignal(str)
def __init__(self, scanner: FileScanner, path: str):
super().__init__()
self.scanner = scanner
self.path = path
def run(self):
try:
sizes = self.scanner.scan_directory_sizes(
self.path,
callback=lambda p: self.progress.emit(p)
)
self.finished.emit(sizes)
except Exception as e:
self.error.emit(str(e))
class MainWindow(QMainWindow):
"""主窗口"""
def __init__(self, sudo_password: Optional[str] = None):
super().__init__()
self.sudo_password = sudo_password
self.disk_manager = DiskManager(sudo_password)
self.file_scanner = FileScanner(sudo_password)
self.current_partition: Optional[Partition] = None
self.current_mount_point: Optional[str] = None
self.dir_sizes = {} # {path: size}
self.init_ui()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle("Linux 日志删除助手")
self.setMinimumSize(900, 600)
# 中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# 顶部信息栏
info_layout = QHBoxLayout()
# 分区信息
self.partition_label = QLabel("当前分区:未选择")
font = QFont()
font.setPointSize(11)
self.partition_label.setFont(font)
info_layout.addWidget(self.partition_label)
info_layout.addStretch()
# 切换分区按钮
self.switch_partition_button = QPushButton("🔄 切换分区")
self.switch_partition_button.clicked.connect(self.switch_partition)
info_layout.addWidget(self.switch_partition_button)
main_layout.addLayout(info_layout)
# 工具栏
toolbar_layout = QHBoxLayout()
# 刷新按钮
self.refresh_button = QPushButton("🔄 刷新")
self.refresh_button.clicked.connect(self.refresh)
toolbar_layout.addWidget(self.refresh_button)
# 统计按钮
self.scan_button = QPushButton("📊 统计目录大小")
self.scan_button.clicked.connect(self.scan_directory_sizes)
toolbar_layout.addWidget(self.scan_button)
# 快捷删除日志按钮
self.delete_logs_button = QPushButton("🗑️ 快捷删除系统日志")
self.delete_logs_button.setStyleSheet(
"QPushButton { background-color: #e74c3c; color: white; font-weight: bold; }"
)
self.delete_logs_button.clicked.connect(self.quick_delete_logs)
self.delete_logs_button.hide() # 默认隐藏,检测到大日志时显示
toolbar_layout.addWidget(self.delete_logs_button)
toolbar_layout.addStretch()
main_layout.addLayout(toolbar_layout)
# 分隔线
separator = QFrame()
separator.setFrameStyle(QFrame.HLine | QFrame.Sunken)
main_layout.addWidget(separator)
# 文件浏览器
self.file_browser = FileBrowser(
self.file_scanner,
self.sudo_password,
self
)
self.file_browser.directory_changed.connect(self.on_directory_changed)
self.file_browser.files_deleted.connect(self.on_files_deleted)
main_layout.addWidget(self.file_browser)
# 状态栏
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("就绪")
# 进度条(用于扫描)
self.progress_bar = QLabel()
self.status_bar.addPermanentWidget(self.progress_bar)
# 默认禁用工具栏按钮
self.refresh_button.setEnabled(False)
self.scan_button.setEnabled(False)
def switch_partition(self):
"""切换分区"""
dialog = PartitionDialog(self.disk_manager, self)
if dialog.exec_() == PartitionDialog.Accepted:
partition = dialog.get_selected_partition()
if partition:
self.load_partition(partition)
def load_partition(self, partition: Partition):
"""加载分区"""
self.current_partition = partition
self.status_bar.showMessage(f"正在挂载分区 {partition.device}...")
# 挂载分区
success, result = self.disk_manager.mount_partition(partition)
if success:
self.current_mount_point = result
# 更新界面
self.partition_label.setText(
f"当前分区:{partition.device} "
f"({partition.mount_point or '未挂载'}) "
f"- {partition.used_formatted} / {partition.size_formatted} "
f"({partition.use_percent_formatted})"
)
# 启用工具栏按钮
self.refresh_button.setEnabled(True)
self.scan_button.setEnabled(True)
# 加载根目录
self.file_browser.load_directory(self.current_mount_point)
# 检测系统日志
self.check_system_logs()
self.status_bar.showMessage(f"已加载分区 {partition.device}")
else:
QMessageBox.critical(
self,
"错误",
f"挂载分区失败:\n{result}"
)
self.status_bar.showMessage("挂载分区失败")
def check_system_logs(self):
"""检测系统日志大小"""
if not self.current_mount_point:
return
logs, total_size = self.file_scanner.get_system_logs(self.current_mount_point)
# 如果大于 1GB,显示快捷删除按钮
if total_size > 1024 * 1024 * 1024:
self.delete_logs_button.setText(
f"🗑️ 快捷删除系统日志 ({format_size(total_size)})"
)
self.delete_logs_button.show()
self._cached_logs = logs
self._cached_logs_size = total_size
else:
self.delete_logs_button.hide()
def quick_delete_logs(self):
"""快捷删除系统日志"""
if not hasattr(self, '_cached_logs') or not self._cached_logs:
return
dialog = LogDeleteConfirmDialog(
self._cached_logs,
self._cached_logs_size,
self
)
if dialog.exec_() == LogDeleteConfirmDialog.Accepted and dialog.is_confirmed():
# 删除日志文件
deleted_size = 0
errors = []
for log_file in self._cached_logs:
success, message = safe_delete(
log_file.path,
use_sudo=True,
sudo_password=self.sudo_password
)
if success:
deleted_size += log_file.size
else:
errors.append(f"{log_file.name}: {message}")
# 显示结果
if errors:
QMessageBox.warning(
self,
"清理完成(有错误)",
f"成功清理 {len(self._cached_logs) - len(errors)} 个日志文件,\n"
f"释放 {format_size(deleted_size)} 空间。\n\n"
f"以下文件清理失败:\n" + "\n".join(errors[:10])
)
else:
QMessageBox.information(
self,
"清理成功",
f"成功清理 {len(self._cached_logs)} 个日志文件,\n"
f"释放 {format_size(deleted_size)} 空间。"
)
# 刷新
self.check_system_logs()
self.refresh()
def scan_directory_sizes(self):
"""统计目录大小"""
if not self.current_mount_point:
return
self.scan_button.setEnabled(False)
self.status_bar.showMessage("正在统计目录大小...")
# 在后台线程统计
self.scan_thread = DirectorySizeThread(
self.file_scanner,
self.current_mount_point
)
self.scan_thread.progress.connect(self.on_scan_progress)
self.scan_thread.finished.connect(self.on_scan_finished)
self.scan_thread.error.connect(self.on_scan_error)
self.scan_thread.start()
def on_scan_progress(self, progress: ScanProgress):
"""扫描进度更新"""
self.progress_bar.setText(
f"已扫描 {progress.scanned_dirs} 个目录, "
f"{progress.scanned_files} 个文件"
)
def on_scan_finished(self, sizes: dict):
"""扫描完成"""
self.dir_sizes = sizes
self.scan_button.setEnabled(True)
self.progress_bar.clear()
# 按大小排序
sorted_sizes = sorted(
sizes.items(),
key=lambda x: x[1],
reverse=True
)
# 显示结果
total_size = sum(sizes.values())
self.status_bar.showMessage(
f"统计完成,共 {len(sizes)} 个目录,"
f"总大小 {format_size(total_size)}"
)
# 可以在这里显示一个统计结果窗口
self.show_scan_results(sorted_sizes)
def on_scan_error(self, error_message: str):
"""扫描出错"""
self.scan_button.setEnabled(True)
self.progress_bar.clear()
self.status_bar.showMessage(f"统计失败:{error_message}")
QMessageBox.critical(
self,
"错误",
f"统计目录大小失败:\n{error_message}"
)
def show_scan_results(self, sorted_sizes: list):
"""显示扫描结果"""
# TODO: 可以创建一个专门的结果窗口
# 目前先在状态栏显示前几个大目录
if sorted_sizes:
top_dirs = sorted_sizes[:5]
info = "最大的目录: " + ", ".join(
f"{os.path.basename(p)} ({format_size(s)})"
for p, s in top_dirs
)
self.status_bar.showMessage(info)
def on_directory_changed(self, path: str):
"""目录改变"""
# 可以在这里更新界面状态
pass
def on_files_deleted(self, freed_size: int):
"""文件被删除"""
# 更新分区使用情况
if self.current_partition:
self.current_partition.used -= freed_size
self.current_partition.available += freed_size
self.partition_label.setText(
f"当前分区:{self.current_partition.device} "
f"({self.current_partition.mount_point or '未挂载'}) "
f"- {self.current_partition.used_formatted} / "
f"{self.current_partition.size_formatted} "
f"({self.current_partition.use_percent_formatted})"
)
def refresh(self):
"""刷新"""
self.file_browser.refresh()
def closeEvent(self, event: QCloseEvent):
"""关闭事件"""
# 卸载所有分区
reply = QMessageBox.question(
self,
"确认退出",
"确定要退出吗?\n已挂载的分区将被卸载。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.disk_manager.unmount_all()
event.accept()
else:
event.ignore()
+246
View File
@@ -0,0 +1,246 @@
"""
分区选择对话框
"""
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QTableWidget, QTableWidgetItem, QPushButton,
QHeaderView, QMessageBox, QProgressBar
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QFont, QColor
from disk_manager import DiskManager, Partition
from utils import format_size
class PartitionLoadThread(QThread):
"""分区加载线程"""
finished = pyqtSignal(list)
error = pyqtSignal(str)
def __init__(self, disk_manager: DiskManager):
super().__init__()
self.disk_manager = disk_manager
def run(self):
try:
partitions = self.disk_manager.get_partitions()
self.finished.emit(partitions)
except Exception as e:
self.error.emit(str(e))
class PartitionDialog(QDialog):
"""分区选择对话框"""
def __init__(self, disk_manager: DiskManager, parent=None):
super().__init__(parent)
self.disk_manager = disk_manager
self.selected_partition = None
self.partitions = []
self.init_ui()
self.load_partitions()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle("选择要清理的分区")
self.setMinimumSize(700, 450)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
# 主布局
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# 标题
title_label = QLabel("请选择要清理的磁盘分区:")
font = QFont()
font.setPointSize(12)
font.setBold(True)
title_label.setFont(font)
layout.addWidget(title_label)
# 分区表格
self.table = QTableWidget()
self.table.setColumnCount(7)
self.table.setHorizontalHeaderLabels([
"设备", "挂载点", "文件系统", "总容量", "已用", "可用", "使用率"
])
# 设置表格属性
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
header.setSectionResizeMode(6, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QTableWidget.SingleSelection)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.doubleClicked.connect(self.on_table_double_clicked)
layout.addWidget(self.table)
# 加载进度条
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 0) # 不确定进度
self.progress_bar.setTextVisible(False)
layout.addWidget(self.progress_bar)
# 状态标签
self.status_label = QLabel("正在检测磁盘分区...")
self.status_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.status_label)
# 按钮
button_layout = QHBoxLayout()
button_layout.addStretch()
self.refresh_button = QPushButton("🔄 刷新")
self.refresh_button.clicked.connect(self.load_partitions)
button_layout.addWidget(self.refresh_button)
self.cancel_button = QPushButton("取消")
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
self.ok_button = QPushButton("确定")
self.ok_button.setDefault(True)
self.ok_button.setEnabled(False)
self.ok_button.clicked.connect(self.on_ok_clicked)
button_layout.addWidget(self.ok_button)
layout.addLayout(button_layout)
def load_partitions(self):
"""加载分区列表"""
self.progress_bar.show()
self.status_label.setText("正在检测磁盘分区...")
self.ok_button.setEnabled(False)
self.refresh_button.setEnabled(False)
# 在后台线程加载分区
self.load_thread = PartitionLoadThread(self.disk_manager)
self.load_thread.finished.connect(self.on_partitions_loaded)
self.load_thread.error.connect(self.on_load_error)
self.load_thread.start()
def on_partitions_loaded(self, partitions):
"""分区加载完成"""
self.partitions = partitions
self.progress_bar.hide()
self.refresh_button.setEnabled(True)
if not partitions:
self.status_label.setText("未检测到可用的分区")
return
self.status_label.setText(f"检测到 {len(partitions)} 个可用分区")
self.update_table()
def on_load_error(self, error_message):
"""加载出错"""
self.progress_bar.hide()
self.refresh_button.setEnabled(True)
self.status_label.setText(f"加载失败:{error_message}")
QMessageBox.critical(
self,
"错误",
f"加载分区列表失败:\n{error_message}"
)
def update_table(self):
"""更新表格内容"""
self.table.setRowCount(len(self.partitions))
for row, partition in enumerate(self.partitions):
# 设备名
device_item = QTableWidgetItem(partition.device)
device_item.setData(Qt.UserRole, row) # 存储索引
self.table.setItem(row, 0, device_item)
# 挂载点
mount_point = partition.mount_point or "未挂载"
mount_item = QTableWidgetItem(mount_point)
self.table.setItem(row, 1, mount_item)
# 文件系统
fs_item = QTableWidgetItem(partition.fstype)
self.table.setItem(row, 2, fs_item)
# 总容量
size_item = QTableWidgetItem(partition.size_formatted)
size_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.table.setItem(row, 3, size_item)
# 已用
used_item = QTableWidgetItem(partition.used_formatted)
used_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.table.setItem(row, 4, used_item)
# 可用
available_item = QTableWidgetItem(partition.available_formatted)
available_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.table.setItem(row, 5, available_item)
# 使用率
percent_item = QTableWidgetItem(partition.use_percent_formatted)
percent_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
# 根据使用率设置颜色
if partition.use_percent >= 90:
percent_item.setForeground(QColor(255, 0, 0)) # 红色
elif partition.use_percent >= 80:
percent_item.setForeground(QColor(255, 165, 0)) # 橙色
elif partition.use_percent >= 70:
percent_item.setForeground(QColor(255, 255, 0)) # 黄色
self.table.setItem(row, 6, percent_item)
# 默认选中第一行
if self.partitions:
self.table.selectRow(0)
self.on_table_clicked(self.table.model().index(0, 0))
def on_table_clicked(self, index):
"""表格点击事件"""
row = index.row()
if 0 <= row < len(self.partitions):
self.selected_partition = self.partitions[row]
self.ok_button.setEnabled(True)
def on_table_double_clicked(self, index):
"""表格双击事件"""
row = index.row()
if 0 <= row < len(self.partitions):
self.selected_partition = self.partitions[row]
self.accept()
def on_ok_clicked(self):
"""确定按钮点击"""
if self.selected_partition:
# 如果使用率超过 90%,显示警告
if self.selected_partition.use_percent >= 90:
reply = QMessageBox.warning(
self,
"警告",
f"分区 {self.selected_partition.device} 使用率已达 "
f"{self.selected_partition.use_percent:.1f}%\n"
f"可用空间仅剩 {self.selected_partition.available_formatted}\n\n"
f"是否继续清理?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply != QMessageBox.Yes:
return
self.accept()
def get_selected_partition(self) -> Partition:
"""获取选中的分区"""
return self.selected_partition
+154
View File
@@ -0,0 +1,154 @@
"""
sudo 密码输入对话框
"""
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QMessageBox
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QIcon
class SudoDialog(QDialog):
"""sudo 密码输入对话框"""
def __init__(self, parent=None):
super().__init__(parent)
self.password = None
self.init_ui()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle("需要 sudo 权限")
self.setFixedSize(400, 200)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
# 主布局
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# 图标和提示
icon_label = QLabel("🔒")
icon_label.setAlignment(Qt.AlignCenter)
font = QFont()
font.setPointSize(36)
icon_label.setFont(font)
layout.addWidget(icon_label)
# 提示文本
hint_label = QLabel(
"本工具需要 sudo 权限来访问磁盘分区和删除文件。\n"
"请输入 sudo 密码:"
)
hint_label.setAlignment(Qt.AlignCenter)
hint_label.setWordWrap(True)
layout.addWidget(hint_label)
# 密码输入框
self.password_input = QLineEdit()
self.password_input.setEchoMode(QLineEdit.Password)
self.password_input.setPlaceholderText("请输入 sudo 密码")
self.password_input.returnPressed.connect(self.on_ok_clicked)
layout.addWidget(self.password_input)
# 按钮
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_button = QPushButton("取消")
self.cancel_button.clicked.connect(self.on_cancel_clicked)
button_layout.addWidget(self.cancel_button)
self.ok_button = QPushButton("确定")
self.ok_button.setDefault(True)
self.ok_button.clicked.connect(self.on_ok_clicked)
button_layout.addWidget(self.ok_button)
layout.addLayout(button_layout)
# 设置焦点
self.password_input.setFocus()
def on_ok_clicked(self):
"""确定按钮点击"""
password = self.password_input.text()
if not password:
QMessageBox.warning(
self,
"警告",
"请输入密码!"
)
return
self.password = password
self.accept()
def on_cancel_clicked(self):
"""取消按钮点击"""
self.password = None
self.reject()
def get_password(self) -> str:
"""获取输入的密码"""
return self.password
class SudoVerifyDialog(QDialog):
"""sudo 密码验证对话框(显示验证进度)"""
def __init__(self, parent=None):
super().__init__(parent)
self.password = None
self.init_ui()
def init_ui(self):
"""初始化界面"""
self.setWindowTitle("验证 sudo 密码")
self.setFixedSize(300, 150)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
# 主布局
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# 提示文本
self.status_label = QLabel("正在验证 sudo 密码...")
self.status_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.status_label)
# 进度指示
self.progress_label = QLabel("")
self.progress_label.setAlignment(Qt.AlignCenter)
font = QFont()
font.setPointSize(24)
self.progress_label.setFont(font)
layout.addWidget(self.progress_label)
# 取消按钮
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_button = QPushButton("取消")
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
layout.addLayout(button_layout)
def set_status(self, status: str):
"""设置状态文本"""
self.status_label.setText(status)
def set_success(self):
"""设置验证成功状态"""
self.progress_label.setText("")
self.status_label.setText("验证成功!")
self.cancel_button.setText("关闭")
def set_failure(self, message: str):
"""设置验证失败状态"""
self.progress_label.setText("")
self.status_label.setText(f"验证失败:{message}")
self.cancel_button.setText("关闭")