首次代码提交
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
eggs/
|
||||||
|
*.whl
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
HANDOVER.md
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Linux 日志删除助手 - 打包脚本
|
||||||
|
# 在 UOS 环境中执行此脚本进行打包
|
||||||
|
|
||||||
|
set -e # 遇到错误立即退出
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Linux 日志删除助手 - 打包脚本"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 检查是否为 root 用户
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${YELLOW}提示:安装依赖需要 sudo 权限${NC}"
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 步骤 1: 检查并安装系统依赖
|
||||||
|
echo ""
|
||||||
|
echo "步骤 1/4: 检查并安装系统依赖..."
|
||||||
|
echo "------------------------------------------"
|
||||||
|
|
||||||
|
# 更新包列表
|
||||||
|
$SUDO apt update
|
||||||
|
|
||||||
|
# 安装 Python3 和 pip(如果未安装)
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "安装 Python3..."
|
||||||
|
$SUDO apt install -y python3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pip3 &> /dev/null; then
|
||||||
|
echo "安装 pip3..."
|
||||||
|
$SUDO apt install -y python3-pip
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 安装 PyQt5
|
||||||
|
echo "安装 PyQt5..."
|
||||||
|
$SUDO apt install -y python3-pyqt5
|
||||||
|
|
||||||
|
# 步骤 2: 安装 Python 依赖
|
||||||
|
echo ""
|
||||||
|
echo "步骤 2/4: 安装 Python 依赖..."
|
||||||
|
echo "------------------------------------------"
|
||||||
|
|
||||||
|
# 安装 PyInstaller
|
||||||
|
pip3 install --user pyinstaller
|
||||||
|
|
||||||
|
# 确保 ~/.local/bin 在 PATH 中
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# 步骤 3: 使用 PyInstaller 打包
|
||||||
|
echo ""
|
||||||
|
echo "步骤 3/4: 打包应用程序..."
|
||||||
|
echo "------------------------------------------"
|
||||||
|
|
||||||
|
# 获取脚本所在目录
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# 清理旧的构建文件
|
||||||
|
rm -rf build dist __pycache__ *.spec
|
||||||
|
|
||||||
|
# 使用 PyInstaller 打包
|
||||||
|
pyinstaller \
|
||||||
|
--onefile \
|
||||||
|
--windowed \
|
||||||
|
--name "log-cleaner" \
|
||||||
|
--add-data "gui:gui" \
|
||||||
|
--hidden-import "PyQt5.QtWidgets" \
|
||||||
|
--hidden-import "PyQt5.QtCore" \
|
||||||
|
--hidden-import "PyQt5.QtGui" \
|
||||||
|
--clean \
|
||||||
|
main.py
|
||||||
|
|
||||||
|
# 步骤 4: 完成
|
||||||
|
echo ""
|
||||||
|
echo "步骤 4/4: 打包完成!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ 打包成功!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "可执行文件位置: dist/log-cleaner"
|
||||||
|
echo ""
|
||||||
|
echo "使用方法:"
|
||||||
|
echo " 1. 将 dist/log-cleaner 复制到 UOS LiveCD 环境"
|
||||||
|
echo " 2. 赋予执行权限: chmod +x log-cleaner"
|
||||||
|
echo " 3. 运行: sudo ./log-cleaner"
|
||||||
|
echo ""
|
||||||
|
echo "注意事项:"
|
||||||
|
echo " - 运行时需要 sudo 权限"
|
||||||
|
echo " - 确保系统已安装 PyQt5 运行时库"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查打包结果
|
||||||
|
if [ -f "dist/log-cleaner" ]; then
|
||||||
|
echo -e "${GREEN}✓ 可执行文件大小: $(du -h dist/log-cleaner | cut -f1)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ 打包失败,请检查错误信息${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+506
@@ -0,0 +1,506 @@
|
|||||||
|
"""
|
||||||
|
磁盘管理模块
|
||||||
|
提供分区检测、挂载、卸载等功能
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from utils import run_command, format_size
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Partition:
|
||||||
|
"""分区信息"""
|
||||||
|
device: str # 设备名,如 /dev/sda1
|
||||||
|
mount_point: str # 挂载点,如 /
|
||||||
|
fstype: str # 文件系统类型,如 ext4
|
||||||
|
size: int # 总大小(字节)
|
||||||
|
used: int # 已用大小(字节)
|
||||||
|
available: int # 可用大小(字节)
|
||||||
|
use_percent: float # 使用百分比
|
||||||
|
label: str = "" # 卷标
|
||||||
|
is_system: bool = False # 是否为系统盘
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_formatted(self) -> str:
|
||||||
|
return format_size(self.size)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def used_formatted(self) -> str:
|
||||||
|
return format_size(self.used)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_formatted(self) -> str:
|
||||||
|
return format_size(self.available)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_percent_formatted(self) -> str:
|
||||||
|
return f"{self.use_percent:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
class DiskManager:
|
||||||
|
"""磁盘管理器"""
|
||||||
|
|
||||||
|
# 排除的文件系统类型
|
||||||
|
EXCLUDED_FS_TYPES = {
|
||||||
|
'swap',
|
||||||
|
'vfat', # 通常是 EFI 分区
|
||||||
|
'iso9660', # 光盘镜像
|
||||||
|
'squashfs', # LiveCD 文件系统
|
||||||
|
'tmpfs', # 临时文件系统
|
||||||
|
'devtmpfs', # 设备文件系统
|
||||||
|
'sysfs', # 内核虚拟文件系统
|
||||||
|
'proc', # 进程虚拟文件系统
|
||||||
|
'devpts', # 设备终端
|
||||||
|
'securityfs',
|
||||||
|
'cgroup',
|
||||||
|
'pstore',
|
||||||
|
'debugfs',
|
||||||
|
'hugetlbfs',
|
||||||
|
'mqueue',
|
||||||
|
'fusectl',
|
||||||
|
'configfs',
|
||||||
|
'overlay', # Overlay 文件系统
|
||||||
|
}
|
||||||
|
|
||||||
|
# 排除的挂载点前缀
|
||||||
|
EXCLUDED_MOUNT_PREFIXES = [
|
||||||
|
'/boot/efi',
|
||||||
|
'/efi',
|
||||||
|
'/boot/EFI',
|
||||||
|
'/snap',
|
||||||
|
'/cdrom',
|
||||||
|
'/media/cdrom',
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, sudo_password: Optional[str] = None):
|
||||||
|
self.sudo_password = sudo_password
|
||||||
|
self.mounted_partitions: Dict[str, str] = {} # device -> mount_point
|
||||||
|
|
||||||
|
def get_partitions(self) -> List[Partition]:
|
||||||
|
"""
|
||||||
|
获取所有可用分区(排除 EFI、swap、LiveCD USB 等)
|
||||||
|
"""
|
||||||
|
partitions = []
|
||||||
|
|
||||||
|
# 获取 LiveCD 设备
|
||||||
|
livecd_device = self._detect_livecd_device()
|
||||||
|
|
||||||
|
# 使用 lsblk 获取分区信息
|
||||||
|
stdout, stderr, returncode = run_command(
|
||||||
|
['lsblk', '-J', '-o', 'NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,PKNAME'],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
# 备用方案:解析 /proc/partitions
|
||||||
|
return self._get_partitions_from_proc()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
devices = data.get('blockdevices', [])
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
self._parse_device(device, partitions, livecd_device)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
print(f"解析 lsblk 输出失败: {e}")
|
||||||
|
return self._get_partitions_from_proc()
|
||||||
|
|
||||||
|
# 获取每个分区的使用情况
|
||||||
|
self._fill_usage_info(partitions)
|
||||||
|
|
||||||
|
# 按使用率排序(高的在前)
|
||||||
|
partitions.sort(key=lambda p: p.use_percent, reverse=True)
|
||||||
|
|
||||||
|
return partitions
|
||||||
|
|
||||||
|
def _parse_device(
|
||||||
|
self,
|
||||||
|
device: dict,
|
||||||
|
partitions: List[Partition],
|
||||||
|
livecd_device: Optional[str]
|
||||||
|
):
|
||||||
|
"""递归解析设备信息"""
|
||||||
|
device_name = device.get('name', '')
|
||||||
|
device_type = device.get('type', '')
|
||||||
|
fstype = device.get('fstype', '')
|
||||||
|
mount_point = device.get('mountpoint', '')
|
||||||
|
label = device.get('label', '')
|
||||||
|
size_str = device.get('size', '0')
|
||||||
|
|
||||||
|
# 只处理分区类型
|
||||||
|
if device_type == 'part':
|
||||||
|
device_path = f"/dev/{device_name}"
|
||||||
|
|
||||||
|
# 排除条件
|
||||||
|
if self._should_exclude(device_path, fstype, mount_point, livecd_device):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 解析大小
|
||||||
|
size = self._parse_size(size_str)
|
||||||
|
|
||||||
|
partition = Partition(
|
||||||
|
device=device_path,
|
||||||
|
mount_point=mount_point or "",
|
||||||
|
fstype=fstype or "unknown",
|
||||||
|
size=size,
|
||||||
|
used=0,
|
||||||
|
available=0,
|
||||||
|
use_percent=0.0,
|
||||||
|
label=label or "",
|
||||||
|
is_system=(mount_point == '/')
|
||||||
|
)
|
||||||
|
|
||||||
|
partitions.append(partition)
|
||||||
|
|
||||||
|
# 递归处理子设备
|
||||||
|
children = device.get('children', [])
|
||||||
|
for child in children:
|
||||||
|
self._parse_device(child, partitions, livecd_device)
|
||||||
|
|
||||||
|
def _should_exclude(
|
||||||
|
self,
|
||||||
|
device: str,
|
||||||
|
fstype: str,
|
||||||
|
mount_point: str,
|
||||||
|
livecd_device: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
"""判断是否应该排除此分区"""
|
||||||
|
# 排除特定文件系统类型
|
||||||
|
if fstype in self.EXCLUDED_FS_TYPES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 排除 EFI 分区
|
||||||
|
if mount_point and any(mount_point.startswith(prefix) for prefix in self.EXCLUDED_MOUNT_PREFIXES):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 排除 LiveCD USB 设备
|
||||||
|
if livecd_device and device.startswith(livecd_device):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 排除没有文件系统的设备
|
||||||
|
if not fstype:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_size(self, size_str: str) -> int:
|
||||||
|
"""解析 lsblk 输出的大小字符串"""
|
||||||
|
if not size_str:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
size_str = size_str.strip()
|
||||||
|
|
||||||
|
# 提取数字和单位
|
||||||
|
try:
|
||||||
|
if size_str.endswith('T'):
|
||||||
|
return int(float(size_str[:-1]) * 1024 * 1024 * 1024 * 1024)
|
||||||
|
elif size_str.endswith('G'):
|
||||||
|
return int(float(size_str[:-1]) * 1024 * 1024 * 1024)
|
||||||
|
elif size_str.endswith('M'):
|
||||||
|
return int(float(size_str[:-1]) * 1024 * 1024)
|
||||||
|
elif size_str.endswith('K'):
|
||||||
|
return int(float(size_str[:-1]) * 1024)
|
||||||
|
else:
|
||||||
|
return int(size_str)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _fill_usage_info(self, partitions: List[Partition]):
|
||||||
|
"""填充分区的使用情况信息"""
|
||||||
|
# 使用 df 命令获取使用情况
|
||||||
|
stdout, stderr, returncode = run_command(
|
||||||
|
['df', '-B1'],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 解析 df 输出
|
||||||
|
df_info = {}
|
||||||
|
for line in stdout.strip().split('\n')[1:]: # 跳过标题行
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 6:
|
||||||
|
device = parts[0]
|
||||||
|
try:
|
||||||
|
size = int(parts[1])
|
||||||
|
used = int(parts[2])
|
||||||
|
available = int(parts[3])
|
||||||
|
use_percent = float(parts[4].rstrip('%'))
|
||||||
|
df_info[device] = {
|
||||||
|
'size': size,
|
||||||
|
'used': used,
|
||||||
|
'available': available,
|
||||||
|
'use_percent': use_percent
|
||||||
|
}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 填充分区信息
|
||||||
|
for partition in partitions:
|
||||||
|
if partition.device in df_info:
|
||||||
|
info = df_info[partition.device]
|
||||||
|
partition.size = info['size']
|
||||||
|
partition.used = info['used']
|
||||||
|
partition.available = info['available']
|
||||||
|
partition.use_percent = info['use_percent']
|
||||||
|
|
||||||
|
def _detect_livecd_device(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
检测 LiveCD USB 设备
|
||||||
|
返回设备名前缀,如 /dev/sdb
|
||||||
|
"""
|
||||||
|
# 方法1: 检查 /proc/cmdline
|
||||||
|
try:
|
||||||
|
with open('/proc/cmdline', 'r') as f:
|
||||||
|
cmdline = f.read()
|
||||||
|
# 查找 boot= 参数
|
||||||
|
for param in cmdline.split():
|
||||||
|
if param.startswith('boot='):
|
||||||
|
boot_device = param.split('=', 1)[1]
|
||||||
|
# 提取设备名(去掉分区号)
|
||||||
|
if boot_device.startswith('/dev/'):
|
||||||
|
return boot_device.rstrip('0123456789')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 方法2: 检查根文件系统类型
|
||||||
|
try:
|
||||||
|
stdout, _, _ = run_command(['findmnt', '-n', '-o', 'SOURCE', '/'])
|
||||||
|
root_device = stdout.strip()
|
||||||
|
if root_device:
|
||||||
|
# 如果根文件系统是 overlay 或 tmpfs,很可能是 LiveCD
|
||||||
|
stdout2, _, _ = run_command(['findmnt', '-n', '-o', 'FSTYPE', '/'])
|
||||||
|
fstype = stdout2.strip()
|
||||||
|
if fstype in ('overlay', 'squashfs', 'tmpfs'):
|
||||||
|
# 查找 USB 设备
|
||||||
|
return self._find_usb_device()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 方法3: 查找标记为 LiveCD 的设备
|
||||||
|
return self._find_usb_device()
|
||||||
|
|
||||||
|
def _find_usb_device(self) -> Optional[str]:
|
||||||
|
"""查找 USB 设备"""
|
||||||
|
try:
|
||||||
|
stdout, _, _ = run_command(
|
||||||
|
['lsblk', '-J', '-o', 'NAME,TRAN,TYPE'],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
|
||||||
|
data = json.loads(stdout)
|
||||||
|
devices = data.get('blockdevices', [])
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
if device.get('tran') == 'usb' and device.get('type') == 'disk':
|
||||||
|
return f"/dev/{device['name']}"
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_partitions_from_proc(self) -> List[Partition]:
|
||||||
|
"""从 /proc/partitions 获取分区信息(备用方案)"""
|
||||||
|
partitions = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, _, returncode = run_command(
|
||||||
|
['cat', '/proc/partitions'],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
livecd_device = self._detect_livecd_device()
|
||||||
|
|
||||||
|
for line in stdout.strip().split('\n')[2:]: # 跳过标题行
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
name = parts[3]
|
||||||
|
# 只处理分区(名称以数字结尾)
|
||||||
|
if name[-1].isdigit():
|
||||||
|
device = f"/dev/{name}"
|
||||||
|
|
||||||
|
# 排除 LiveCD 设备
|
||||||
|
if livecd_device and device.startswith(livecd_device):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取文件系统类型
|
||||||
|
fstype = self._get_fstype(device)
|
||||||
|
|
||||||
|
# 排除不需要的文件系统类型
|
||||||
|
if fstype in self.EXCLUDED_FS_TYPES or not fstype:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取挂载点
|
||||||
|
mount_point = self._get_mount_point(device)
|
||||||
|
|
||||||
|
# 排除 EFI 分区
|
||||||
|
if mount_point and any(
|
||||||
|
mount_point.startswith(prefix)
|
||||||
|
for prefix in self.EXCLUDED_MOUNT_PREFIXES
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
size = int(parts[2]) * 1024 # 转换为字节
|
||||||
|
|
||||||
|
partition = Partition(
|
||||||
|
device=device,
|
||||||
|
mount_point=mount_point or "",
|
||||||
|
fstype=fstype,
|
||||||
|
size=size,
|
||||||
|
used=0,
|
||||||
|
available=0,
|
||||||
|
use_percent=0.0,
|
||||||
|
is_system=(mount_point == '/')
|
||||||
|
)
|
||||||
|
|
||||||
|
partitions.append(partition)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"从 /proc/partitions 获取信息失败: {e}")
|
||||||
|
|
||||||
|
# 填充使用情况
|
||||||
|
self._fill_usage_info(partitions)
|
||||||
|
|
||||||
|
return partitions
|
||||||
|
|
||||||
|
def _get_fstype(self, device: str) -> str:
|
||||||
|
"""获取设备的文件系统类型"""
|
||||||
|
stdout, _, _ = run_command(
|
||||||
|
['blkid', '-o', 'value', '-s', 'TYPE', device],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
return stdout.strip()
|
||||||
|
|
||||||
|
def _get_mount_point(self, device: str) -> str:
|
||||||
|
"""获取设备的挂载点"""
|
||||||
|
stdout, _, _ = run_command(
|
||||||
|
['findmnt', '-n', '-o', 'TARGET', device],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
return stdout.strip()
|
||||||
|
|
||||||
|
def mount_partition(self, partition: Partition, mount_point: Optional[str] = None) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
挂载分区到临时目录
|
||||||
|
|
||||||
|
参数:
|
||||||
|
partition: 分区信息
|
||||||
|
mount_point: 指定挂载点(可选)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(success, mount_point_or_error_message)
|
||||||
|
"""
|
||||||
|
# 如果已经挂载,返回现有挂载点
|
||||||
|
if partition.mount_point:
|
||||||
|
return True, partition.mount_point
|
||||||
|
|
||||||
|
# 如果已挂载到临时目录
|
||||||
|
if partition.device in self.mounted_partitions:
|
||||||
|
return True, self.mounted_partitions[partition.device]
|
||||||
|
|
||||||
|
# 创建临时挂载点
|
||||||
|
if mount_point is None:
|
||||||
|
mount_point = tempfile.mkdtemp(prefix='log_cleaner_')
|
||||||
|
|
||||||
|
# 挂载分区
|
||||||
|
stdout, stderr, returncode = run_command(
|
||||||
|
['mount', partition.device, mount_point],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode == 0:
|
||||||
|
partition.mount_point = mount_point
|
||||||
|
self.mounted_partitions[partition.device] = mount_point
|
||||||
|
return True, mount_point
|
||||||
|
else:
|
||||||
|
return False, f"挂载失败: {stderr}"
|
||||||
|
|
||||||
|
def unmount_partition(self, partition: Partition) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
卸载分区
|
||||||
|
|
||||||
|
参数:
|
||||||
|
partition: 分区信息
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
# 如果是系统挂载点,不卸载
|
||||||
|
if partition.is_system:
|
||||||
|
return False, "不能卸载系统分区"
|
||||||
|
|
||||||
|
mount_point = partition.mount_point
|
||||||
|
|
||||||
|
if not mount_point:
|
||||||
|
return False, "分区未挂载"
|
||||||
|
|
||||||
|
# 如果是临时挂载点,卸载
|
||||||
|
if partition.device in self.mounted_partitions:
|
||||||
|
stdout, stderr, returncode = run_command(
|
||||||
|
['umount', mount_point],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode == 0:
|
||||||
|
del self.mounted_partitions[partition.device]
|
||||||
|
partition.mount_point = ""
|
||||||
|
|
||||||
|
# 删除临时目录
|
||||||
|
try:
|
||||||
|
os.rmdir(mount_point)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True, "卸载成功"
|
||||||
|
else:
|
||||||
|
return False, f"卸载失败: {stderr}"
|
||||||
|
|
||||||
|
return False, "分区不是由本工具挂载的"
|
||||||
|
|
||||||
|
def unmount_all(self):
|
||||||
|
"""卸载所有由本工具挂载的分区"""
|
||||||
|
for device in list(self.mounted_partitions.keys()):
|
||||||
|
mount_point = self.mounted_partitions[device]
|
||||||
|
run_command(
|
||||||
|
['umount', mount_point],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.rmdir(mount_point)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.mounted_partitions.clear()
|
||||||
|
|
||||||
|
def get_partition_display_name(self, partition: Partition) -> str:
|
||||||
|
"""获取分区的显示名称"""
|
||||||
|
parts = [partition.device]
|
||||||
|
|
||||||
|
if partition.mount_point:
|
||||||
|
parts.append(f"({partition.mount_point})")
|
||||||
|
elif partition.label:
|
||||||
|
parts.append(f"({partition.label})")
|
||||||
|
|
||||||
|
if partition.is_system:
|
||||||
|
parts.append("[系统盘]")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
+489
@@ -0,0 +1,489 @@
|
|||||||
|
"""
|
||||||
|
文件扫描模块
|
||||||
|
提供目录大小扫描、文件列表等功能
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import List, Dict, Optional, Callable, Tuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from utils import format_size, is_virtual_filesystem, run_command
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileItem:
|
||||||
|
"""文件/目录信息"""
|
||||||
|
name: str # 名称
|
||||||
|
path: str # 完整路径
|
||||||
|
is_dir: bool # 是否为目录
|
||||||
|
size: int # 大小(字节)
|
||||||
|
modified_time: datetime # 修改时间
|
||||||
|
permissions: str # 权限字符串
|
||||||
|
owner: str # 所有者
|
||||||
|
group: str # 所属组
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_formatted(self) -> str:
|
||||||
|
return format_size(self.size)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modified_time_formatted(self) -> str:
|
||||||
|
return self.modified_time.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str:
|
||||||
|
if self.is_dir:
|
||||||
|
return "📁"
|
||||||
|
elif self.name.endswith(('.log', '.log.gz', '.log.1', '.log.2')):
|
||||||
|
return "📋"
|
||||||
|
elif self.name.endswith(('.gz', '.tar', '.zip', '.7z', '.rar')):
|
||||||
|
return "📦"
|
||||||
|
elif self.name.endswith(('.conf', '.cfg', '.ini', '.yaml', '.yml', '.json')):
|
||||||
|
return "⚙️"
|
||||||
|
elif self.name.endswith(('.sh', '.bash', '.py', '.pl')):
|
||||||
|
return "📜"
|
||||||
|
elif self.name.endswith(('.txt', '.md', '.rst')):
|
||||||
|
return "📄"
|
||||||
|
else:
|
||||||
|
return "📄"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanProgress:
|
||||||
|
"""扫描进度信息"""
|
||||||
|
current_path: str = ""
|
||||||
|
scanned_dirs: int = 0
|
||||||
|
scanned_files: int = 0
|
||||||
|
total_size: int = 0
|
||||||
|
elapsed_time: float = 0.0
|
||||||
|
is_complete: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FileScanner:
|
||||||
|
"""文件扫描器"""
|
||||||
|
|
||||||
|
# 虚拟文件系统目录列表
|
||||||
|
VIRTUAL_DIRS = [
|
||||||
|
'/proc',
|
||||||
|
'/sys',
|
||||||
|
'/dev',
|
||||||
|
'/run',
|
||||||
|
'/var/run',
|
||||||
|
'/var/lock',
|
||||||
|
'/tmp',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 系统日志文件模式(不包含子目录中的日志)
|
||||||
|
SYSTEM_LOG_PATTERNS = [
|
||||||
|
'*.log',
|
||||||
|
'*.log.*',
|
||||||
|
'*.gz',
|
||||||
|
'syslog*',
|
||||||
|
'messages*',
|
||||||
|
'dmesg*',
|
||||||
|
'kern.log*',
|
||||||
|
'auth.log*',
|
||||||
|
'daemon.log*',
|
||||||
|
'debug*',
|
||||||
|
'boot.log*',
|
||||||
|
'faillog',
|
||||||
|
'lastlog',
|
||||||
|
'wtmp',
|
||||||
|
'btmp',
|
||||||
|
'utmp',
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, sudo_password: Optional[str] = None):
|
||||||
|
self.sudo_password = sudo_password
|
||||||
|
self._cancel_scan = False
|
||||||
|
|
||||||
|
def scan_directory_sizes(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
skip_virtual: bool = True,
|
||||||
|
callback: Optional[Callable[[ScanProgress], None]] = None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
扫描目录下每个子目录的大小
|
||||||
|
|
||||||
|
参数:
|
||||||
|
path: 要扫描的目录路径
|
||||||
|
skip_virtual: 是否跳过虚拟文件系统目录
|
||||||
|
callback: 进度回调函数
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{目录路径: 大小} 字典
|
||||||
|
"""
|
||||||
|
self._cancel_scan = False
|
||||||
|
dir_sizes: Dict[str, int] = {}
|
||||||
|
progress = ScanProgress()
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取顶层子目录
|
||||||
|
entries = self._list_directory(path)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if self._cancel_scan:
|
||||||
|
break
|
||||||
|
|
||||||
|
entry_path = os.path.join(path, entry)
|
||||||
|
|
||||||
|
# 跳过虚拟文件系统
|
||||||
|
if skip_virtual and self._is_virtual_dir(entry_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 跳过其他挂载点
|
||||||
|
if self._is_other_mountpoint(entry_path, path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算目录大小
|
||||||
|
size = self._get_directory_size(entry_path, progress, callback)
|
||||||
|
dir_sizes[entry_path] = size
|
||||||
|
progress.total_size += size
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"扫描目录时发生错误: {e}")
|
||||||
|
|
||||||
|
progress.is_complete = True
|
||||||
|
progress.elapsed_time = time.time() - start_time
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
callback(progress)
|
||||||
|
|
||||||
|
return dir_sizes
|
||||||
|
|
||||||
|
def list_directory(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
show_hidden: bool = False,
|
||||||
|
sort_by: str = 'size',
|
||||||
|
reverse: bool = True
|
||||||
|
) -> List[FileItem]:
|
||||||
|
"""
|
||||||
|
列出目录下的文件和子目录
|
||||||
|
|
||||||
|
参数:
|
||||||
|
path: 目录路径
|
||||||
|
show_hidden: 是否显示隐藏文件
|
||||||
|
sort_by: 排序方式 (name, size, time)
|
||||||
|
reverse: 是否逆序
|
||||||
|
|
||||||
|
返回:
|
||||||
|
FileItem 列表
|
||||||
|
"""
|
||||||
|
items: List[FileItem] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = self._list_directory(path)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
# 跳过隐藏文件(除非要求显示)
|
||||||
|
if not show_hidden and entry.startswith('.'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry_path = os.path.join(path, entry)
|
||||||
|
|
||||||
|
try:
|
||||||
|
item = self._get_file_item(entry_path, entry)
|
||||||
|
if item:
|
||||||
|
items.append(item)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
# 跳过无权限访问的文件
|
||||||
|
pass
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"列出目录时发生错误: {e}")
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
if sort_by == 'name':
|
||||||
|
items.sort(key=lambda x: x.name.lower(), reverse=reverse)
|
||||||
|
elif sort_by == 'size':
|
||||||
|
items.sort(key=lambda x: x.size, reverse=reverse)
|
||||||
|
elif sort_by == 'time':
|
||||||
|
items.sort(key=lambda x: x.modified_time, reverse=reverse)
|
||||||
|
|
||||||
|
# 目录排在前面
|
||||||
|
items.sort(key=lambda x: (not x.is_dir, -x.size if reverse else x.size))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def get_system_logs(self, mount_point: str) -> Tuple[List[FileItem], int]:
|
||||||
|
"""
|
||||||
|
获取 /var/log 下的系统日志文件(不包含子目录中的日志)
|
||||||
|
|
||||||
|
参数:
|
||||||
|
mount_point: 分区挂载点
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(日志文件列表, 总大小)
|
||||||
|
"""
|
||||||
|
log_dir = os.path.join(mount_point, 'var', 'log')
|
||||||
|
|
||||||
|
if not os.path.exists(log_dir):
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
logs: List[FileItem] = []
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = self._list_directory(log_dir)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
entry_path = os.path.join(log_dir, entry)
|
||||||
|
|
||||||
|
# 只处理文件,不处理子目录
|
||||||
|
if not os.path.isfile(entry_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否为日志文件
|
||||||
|
if self._is_log_file(entry):
|
||||||
|
try:
|
||||||
|
item = self._get_file_item(entry_path, entry)
|
||||||
|
if item:
|
||||||
|
logs.append(item)
|
||||||
|
total_size += item.size
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取系统日志时发生错误: {e}")
|
||||||
|
|
||||||
|
# 按大小排序
|
||||||
|
logs.sort(key=lambda x: x.size, reverse=True)
|
||||||
|
|
||||||
|
return logs, total_size
|
||||||
|
|
||||||
|
def cancel_scan(self):
|
||||||
|
"""取消正在进行的扫描"""
|
||||||
|
self._cancel_scan = True
|
||||||
|
|
||||||
|
def _list_directory(self, path: str) -> List[str]:
|
||||||
|
"""列出目录内容"""
|
||||||
|
try:
|
||||||
|
return os.listdir(path)
|
||||||
|
except PermissionError:
|
||||||
|
# 尝试使用 sudo
|
||||||
|
if self.sudo_password:
|
||||||
|
stdout, _, returncode = run_command(
|
||||||
|
['ls', '-a', path],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password
|
||||||
|
)
|
||||||
|
if returncode == 0:
|
||||||
|
return [f for f in stdout.strip().split('\n') if f and f not in ('.', '..')]
|
||||||
|
return []
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_directory_size(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
progress: ScanProgress,
|
||||||
|
callback: Optional[Callable[[ScanProgress], None]] = None
|
||||||
|
) -> int:
|
||||||
|
"""递归计算目录大小"""
|
||||||
|
if self._cancel_scan:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = self._list_directory(path)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if self._cancel_scan:
|
||||||
|
break
|
||||||
|
|
||||||
|
entry_path = os.path.join(path, entry)
|
||||||
|
|
||||||
|
# 跳过虚拟文件系统
|
||||||
|
if self._is_virtual_dir(entry_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 跳过其他挂载点
|
||||||
|
if self._is_other_mountpoint(entry_path, path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.islink(entry_path):
|
||||||
|
# 跳过符号链接
|
||||||
|
continue
|
||||||
|
elif os.path.isdir(entry_path):
|
||||||
|
# 递归计算子目录
|
||||||
|
progress.current_path = entry_path
|
||||||
|
progress.scanned_dirs += 1
|
||||||
|
|
||||||
|
if callback and progress.scanned_dirs % 100 == 0:
|
||||||
|
callback(progress)
|
||||||
|
|
||||||
|
total_size += self._get_directory_size(entry_path, progress, callback)
|
||||||
|
else:
|
||||||
|
# 获取文件大小
|
||||||
|
try:
|
||||||
|
file_size = os.path.getsize(entry_path)
|
||||||
|
total_size += file_size
|
||||||
|
progress.scanned_files += 1
|
||||||
|
progress.total_size += file_size
|
||||||
|
|
||||||
|
if callback and progress.scanned_files % 1000 == 0:
|
||||||
|
callback(progress)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
# 无权限访问
|
||||||
|
pass
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
# 无权限访问目录,尝试使用 sudo
|
||||||
|
if self.sudo_password:
|
||||||
|
total_size = self._get_directory_size_sudo(path)
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def _get_directory_size_sudo(self, path: str) -> int:
|
||||||
|
"""使用 sudo 获取目录大小"""
|
||||||
|
stdout, _, returncode = run_command(
|
||||||
|
['du', '-sb', path],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode == 0:
|
||||||
|
try:
|
||||||
|
return int(stdout.split()[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _get_file_item(self, path: str, name: str) -> Optional[FileItem]:
|
||||||
|
"""获取文件/目录信息"""
|
||||||
|
try:
|
||||||
|
stat_info = os.lstat(path)
|
||||||
|
|
||||||
|
# 获取所有者和组
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
import grp
|
||||||
|
owner = pwd.getpwuid(stat_info.st_uid).pw_name
|
||||||
|
group = grp.getgrgid(stat_info.st_gid).gr_name
|
||||||
|
except (ImportError, KeyError):
|
||||||
|
owner = str(stat_info.st_uid)
|
||||||
|
group = str(stat_info.st_gid)
|
||||||
|
|
||||||
|
# 获取权限字符串
|
||||||
|
permissions = self._format_permissions(stat_info.st_mode)
|
||||||
|
|
||||||
|
# 如果是目录,获取目录大小(使用 du)
|
||||||
|
if os.path.isdir(path):
|
||||||
|
size = self._get_dir_size_fast(path)
|
||||||
|
else:
|
||||||
|
size = stat_info.st_size
|
||||||
|
|
||||||
|
return FileItem(
|
||||||
|
name=name,
|
||||||
|
path=path,
|
||||||
|
is_dir=os.path.isdir(path),
|
||||||
|
size=size,
|
||||||
|
modified_time=datetime.fromtimestamp(stat_info.st_mtime),
|
||||||
|
permissions=permissions,
|
||||||
|
owner=owner,
|
||||||
|
group=group
|
||||||
|
)
|
||||||
|
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_dir_size_fast(self, path: str) -> int:
|
||||||
|
"""快速获取目录大小(使用 du 命令)"""
|
||||||
|
try:
|
||||||
|
stdout, _, returncode = run_command(
|
||||||
|
['du', '-sb', path],
|
||||||
|
use_sudo=True,
|
||||||
|
sudo_password=self.sudo_password,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode == 0:
|
||||||
|
return int(stdout.split()[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _format_permissions(self, mode: int) -> str:
|
||||||
|
"""格式化文件权限为字符串"""
|
||||||
|
perms = []
|
||||||
|
|
||||||
|
# 文件类型
|
||||||
|
if os.path.isdir(mode):
|
||||||
|
perms.append('d')
|
||||||
|
elif os.path.islink(mode):
|
||||||
|
perms.append('l')
|
||||||
|
else:
|
||||||
|
perms.append('-')
|
||||||
|
|
||||||
|
# 所有者权限
|
||||||
|
perms.append('r' if mode & 0o400 else '-')
|
||||||
|
perms.append('w' if mode & 0o200 else '-')
|
||||||
|
perms.append('x' if mode & 0o100 else '-')
|
||||||
|
|
||||||
|
# 组权限
|
||||||
|
perms.append('r' if mode & 0o040 else '-')
|
||||||
|
perms.append('w' if mode & 0o020 else '-')
|
||||||
|
perms.append('x' if mode & 0o010 else '-')
|
||||||
|
|
||||||
|
# 其他用户权限
|
||||||
|
perms.append('r' if mode & 0o004 else '-')
|
||||||
|
perms.append('w' if mode & 0o002 else '-')
|
||||||
|
perms.append('x' if mode & 0o001 else '-')
|
||||||
|
|
||||||
|
return ''.join(perms)
|
||||||
|
|
||||||
|
def _is_virtual_dir(self, path: str) -> bool:
|
||||||
|
"""检查是否为虚拟文件系统目录"""
|
||||||
|
path = os.path.normpath(path)
|
||||||
|
for vdir in self.VIRTUAL_DIRS:
|
||||||
|
if path == vdir or path.startswith(vdir + '/'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_other_mountpoint(self, path: str, base_path: str) -> bool:
|
||||||
|
"""检查是否为其他挂载点"""
|
||||||
|
try:
|
||||||
|
# 如果路径和基础路径的设备号不同,说明是不同的挂载点
|
||||||
|
base_stat = os.stat(base_path)
|
||||||
|
path_stat = os.stat(path)
|
||||||
|
|
||||||
|
if base_stat.st_dev != path_stat.st_dev:
|
||||||
|
return True
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_log_file(self, filename: str) -> bool:
|
||||||
|
"""检查是否为日志文件"""
|
||||||
|
import fnmatch
|
||||||
|
|
||||||
|
for pattern in self.SYSTEM_LOG_PATTERNS:
|
||||||
|
if fnmatch.fnmatch(filename, pattern):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 检查文件扩展名
|
||||||
|
log_extensions = ['.log', '.log.gz', '.log.1', '.log.2', '.log.3']
|
||||||
|
for ext in log_extensions:
|
||||||
|
if filename.endswith(ext):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Linux 日志删除助手 - GUI 模块
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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("关闭")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Linux 日志删除助手
|
||||||
|
在 UOS LiveCD 环境中运行,帮助用户快速清理磁盘空间
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 添加当前目录到 Python 路径
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if current_dir not in sys.path:
|
||||||
|
sys.path.insert(0, current_dir)
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import QApplication, QMessageBox
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QFont
|
||||||
|
|
||||||
|
from utils import check_sudo_needs_password, get_sudo_password, verify_sudo_password
|
||||||
|
from gui.main_window import MainWindow
|
||||||
|
from gui.sudo_dialog import SudoDialog
|
||||||
|
|
||||||
|
|
||||||
|
def check_environment():
|
||||||
|
"""检查运行环境"""
|
||||||
|
# 检查是否为 Linux 系统
|
||||||
|
if sys.platform != 'linux':
|
||||||
|
QMessageBox.warning(
|
||||||
|
None,
|
||||||
|
"环境警告",
|
||||||
|
"本工具设计用于 Linux 系统,\n"
|
||||||
|
"当前运行在非 Linux 环境中,部分功能可能无法正常工作。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否有 root 权限或 sudo
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
if check_sudo_needs_password():
|
||||||
|
return True # 需要密码,稍后会提示
|
||||||
|
else:
|
||||||
|
# sudo 不需要密码,可以直接使用
|
||||||
|
return True
|
||||||
|
|
||||||
|
return True # 已经是 root
|
||||||
|
|
||||||
|
|
||||||
|
def get_sudo_access():
|
||||||
|
"""获取 sudo 权限"""
|
||||||
|
# 检查是否已经是 root
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
return None # 不需要密码
|
||||||
|
|
||||||
|
# 检查 sudo 是否需要密码
|
||||||
|
if not check_sudo_needs_password():
|
||||||
|
return None # 不需要密码
|
||||||
|
|
||||||
|
# 需要密码,显示密码输入对话框
|
||||||
|
dialog = SudoDialog()
|
||||||
|
|
||||||
|
if dialog.exec_() == SudoDialog.Accepted:
|
||||||
|
password = dialog.get_password()
|
||||||
|
|
||||||
|
# 验证密码
|
||||||
|
if verify_sudo_password(password):
|
||||||
|
return password
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(
|
||||||
|
None,
|
||||||
|
"密码错误",
|
||||||
|
"sudo 密码不正确,程序将退出。"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# 用户取消
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
# 设置高 DPI 支持
|
||||||
|
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||||
|
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||||
|
|
||||||
|
# 创建应用
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# 设置应用样式
|
||||||
|
app.setStyle('Fusion')
|
||||||
|
|
||||||
|
# 设置默认字体
|
||||||
|
font = QFont()
|
||||||
|
font.setPointSize(10)
|
||||||
|
app.setFont(font)
|
||||||
|
|
||||||
|
# 检查环境
|
||||||
|
if not check_environment():
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 获取 sudo 权限
|
||||||
|
sudo_password = get_sudo_access()
|
||||||
|
|
||||||
|
# 如果需要密码但没有提供,且不是 root,则退出
|
||||||
|
if sudo_password is None and os.geteuid() != 0:
|
||||||
|
if check_sudo_needs_password():
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 创建主窗口
|
||||||
|
window = MainWindow(sudo_password)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
# 启动时自动显示分区选择对话框
|
||||||
|
window.switch_partition()
|
||||||
|
|
||||||
|
# 运行应用
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PyQt5>=5.15.0
|
||||||
|
pyinstaller>=5.0
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""
|
||||||
|
工具函数模块
|
||||||
|
提供 sudo 检测、密码获取、命令执行等功能
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def check_sudo_needs_password() -> bool:
|
||||||
|
"""
|
||||||
|
检测 sudo 是否需要密码
|
||||||
|
返回 True 表示需要密码,False 表示不需要
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 使用 sudo -n (non-interactive) 检测是否需要密码
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', '-n', 'true'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
# 返回码为 0 表示不需要密码
|
||||||
|
return result.returncode != 0
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
# 如果 sudo 命令不存在或超时,假设需要密码
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_sudo_password() -> Optional[str]:
|
||||||
|
"""
|
||||||
|
通过 GUI 对话框获取 sudo 密码
|
||||||
|
返回密码字符串,如果用户取消则返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PyQt5.QtWidgets import QInputDialog, QLineEdit
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
password, ok = QInputDialog.getText(
|
||||||
|
None,
|
||||||
|
"需要 sudo 权限",
|
||||||
|
"请输入 sudo 密码:",
|
||||||
|
QLineEdit.Password
|
||||||
|
)
|
||||||
|
|
||||||
|
if ok and password:
|
||||||
|
return password
|
||||||
|
return None
|
||||||
|
except ImportError:
|
||||||
|
# 如果 PyQt5 不可用,使用命令行方式
|
||||||
|
return _get_sudo_password_cli()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sudo_password_cli() -> Optional[str]:
|
||||||
|
"""
|
||||||
|
通过命令行获取 sudo 密码(备用方案)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import getpass
|
||||||
|
password = getpass.getpass("请输入 sudo 密码: ")
|
||||||
|
return password if password else None
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_sudo_password(password: str) -> bool:
|
||||||
|
"""
|
||||||
|
验证 sudo 密码是否正确
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', '-S', 'true'],
|
||||||
|
input=password + '\n',
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
cmd: list,
|
||||||
|
use_sudo: bool = False,
|
||||||
|
sudo_password: Optional[str] = None,
|
||||||
|
timeout: int = 30
|
||||||
|
) -> Tuple[str, str, int]:
|
||||||
|
"""
|
||||||
|
执行系统命令
|
||||||
|
|
||||||
|
参数:
|
||||||
|
cmd: 命令列表,如 ['ls', '-la']
|
||||||
|
use_sudo: 是否使用 sudo
|
||||||
|
sudo_password: sudo 密码(如果为 None 且 use_sudo=True,则使用 -n 模式)
|
||||||
|
timeout: 超时时间(秒)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(stdout, stderr, returncode)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if use_sudo:
|
||||||
|
if sudo_password:
|
||||||
|
# 使用 -S 从 stdin 读取密码
|
||||||
|
full_cmd = ['sudo', '-S'] + cmd
|
||||||
|
result = subprocess.run(
|
||||||
|
full_cmd,
|
||||||
|
input=sudo_password + '\n',
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 使用 -n 非交互模式
|
||||||
|
full_cmd = ['sudo', '-n'] + cmd
|
||||||
|
result = subprocess.run(
|
||||||
|
full_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.stdout, result.stderr, result.returncode
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "", f"命令超时 ({timeout}秒)", -1
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "", f"命令不存在: {cmd[0]}", -1
|
||||||
|
except Exception as e:
|
||||||
|
return "", str(e), -1
|
||||||
|
|
||||||
|
|
||||||
|
def format_size(size_bytes: int) -> str:
|
||||||
|
"""
|
||||||
|
将字节数格式化为人类可读的大小
|
||||||
|
"""
|
||||||
|
if size_bytes < 0:
|
||||||
|
return "未知"
|
||||||
|
|
||||||
|
units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
unit_index = 0
|
||||||
|
size = float(size_bytes)
|
||||||
|
|
||||||
|
while size >= 1024.0 and unit_index < len(units) - 1:
|
||||||
|
size /= 1024.0
|
||||||
|
unit_index += 1
|
||||||
|
|
||||||
|
if unit_index == 0:
|
||||||
|
return f"{int(size)} B"
|
||||||
|
else:
|
||||||
|
return f"{size:.1f} {units[unit_index]}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_root() -> bool:
|
||||||
|
"""
|
||||||
|
检查当前用户是否为 root
|
||||||
|
"""
|
||||||
|
return os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_mount_point(path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取路径所在的挂载点
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = os.path.realpath(path)
|
||||||
|
while path != '/':
|
||||||
|
if os.path.ismount(path):
|
||||||
|
return path
|
||||||
|
path = os.path.dirname(path)
|
||||||
|
return '/'
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_virtual_filesystem(path: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查路径是否为虚拟文件系统
|
||||||
|
"""
|
||||||
|
virtual_fs_paths = [
|
||||||
|
'/proc',
|
||||||
|
'/sys',
|
||||||
|
'/dev',
|
||||||
|
'/run',
|
||||||
|
'/tmp',
|
||||||
|
'/var/run',
|
||||||
|
'/var/lock',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 规范化路径
|
||||||
|
path = os.path.normpath(path)
|
||||||
|
|
||||||
|
for vfspath in virtual_fs_paths:
|
||||||
|
if path == vfspath or path.startswith(vfspath + '/'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def safe_delete(path: str, use_sudo: bool = True, sudo_password: Optional[str] = None) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
安全删除文件或目录
|
||||||
|
|
||||||
|
参数:
|
||||||
|
path: 要删除的路径
|
||||||
|
use_sudo: 是否使用 sudo
|
||||||
|
sudo_password: sudo 密码
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return False, f"路径不存在: {path}"
|
||||||
|
|
||||||
|
# 获取删除前的大小
|
||||||
|
try:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
else:
|
||||||
|
size = get_directory_size(path)
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
# 执行删除
|
||||||
|
try:
|
||||||
|
if os.path.isfile(path) or os.path.islink(path):
|
||||||
|
cmd = ['rm', '-f', path]
|
||||||
|
else:
|
||||||
|
cmd = ['rm', '-rf', path]
|
||||||
|
|
||||||
|
stdout, stderr, returncode = run_command(
|
||||||
|
cmd,
|
||||||
|
use_sudo=use_sudo,
|
||||||
|
sudo_password=sudo_password,
|
||||||
|
timeout=300 # 删除大目录可能需要较长时间
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode == 0:
|
||||||
|
return True, f"成功删除,释放 {format_size(size)}"
|
||||||
|
else:
|
||||||
|
return False, f"删除失败: {stderr}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"删除时发生错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_directory_size(path: str) -> int:
|
||||||
|
"""
|
||||||
|
获取目录大小(字节)
|
||||||
|
"""
|
||||||
|
total_size = 0
|
||||||
|
try:
|
||||||
|
for dirpath, dirnames, filenames in os.walk(path):
|
||||||
|
# 跳过虚拟文件系统
|
||||||
|
if is_virtual_filesystem(dirpath):
|
||||||
|
dirnames.clear()
|
||||||
|
continue
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
filepath = os.path.join(dirpath, filename)
|
||||||
|
try:
|
||||||
|
if not os.path.islink(filepath):
|
||||||
|
total_size += os.path.getsize(filepath)
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""
|
||||||
|
配置日志系统
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.FileHandler('/tmp/log_cleaner.log', encoding='utf-8')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return logging.getLogger('LogCleaner')
|
||||||
Reference in New Issue
Block a user