10 Commits

Author SHA1 Message Date
Julian Freeman
94fb93eed1 remove some dir 2026-04-17 12:52:21 -04:00
Julian Freeman
dab7209a69 upgrade 2026-04-17 12:26:35 -04:00
Julian Freeman
4e40fa9f80 support clean orogress 2026-04-17 12:25:40 -04:00
Julian Freeman
54b8701644 alert before clean recycle.bin 2026-04-17 12:10:24 -04:00
Julian Freeman
8764af1a56 alert before clean browsers 2026-04-17 12:05:33 -04:00
Julian Freeman
ab1da1ff0e add recycle.bin to fast_clean 2026-04-17 11:13:06 -04:00
Julian Freeman
fd2566ca26 refactor backend 2026-04-17 11:09:49 -04:00
Julian Freeman
40215bcbcb refactor frontend style 2026-04-17 11:00:42 -04:00
Julian Freeman
11a8955aca refactor frontend 2026-04-17 10:39:25 -04:00
Julian Freeman
9e06791019 desc optimize 2026-03-17 18:42:12 -04:00
37 changed files with 3559 additions and 2434 deletions

View File

@@ -1,3 +1,3 @@
# Windows 清理工具
用 Gemini CLI 生成。
用 Gemini CLI & Codex 生成。

View File

@@ -1,7 +1,7 @@
{
"name": "win-cleaner",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,6 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion

2
src-tauri/Cargo.lock generated
View File

@@ -4594,7 +4594,7 @@ dependencies = [
[[package]]
name = "win-cleaner"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"chrono",
"jwalk",

View File

@@ -1,6 +1,6 @@
[package]
name = "win-cleaner"
version = "0.1.0"
version = "0.1.1"
description = "A Windows Cleaner"
authors = ["Julian"]
edition = "2021"

View File

@@ -0,0 +1,61 @@
use std::fs;
use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::Command;
pub async fn run_dism_cleanup() -> Result<String, String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("dism.exe")
.args(["/online", "/Cleanup-Image", "/StartComponentCleanup"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok("系统组件清理完成。".into())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
pub async fn clean_thumbnails() -> Result<String, String> {
let local_app_data = std::env::var("LOCALAPPDATA").map_err(|_| "无法获取 LocalAppData 路径")?;
let thumb_path = Path::new(&local_app_data).join("Microsoft\\Windows\\Explorer");
let mut count = 0;
if thumb_path.exists() {
if let Ok(entries) = fs::read_dir(thumb_path) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_lowercase();
if name.starts_with("thumbcache_") && name.ends_with(".db") {
if fs::remove_file(entry.path()).is_ok() {
count += 1;
}
}
}
}
}
if count > 0 {
Ok(format!("成功清理 {} 个缩略图缓存文件。", count))
} else {
Ok("未发现可清理的缩略图缓存,或文件正被系统占用。".into())
}
}
pub async fn disable_hibernation() -> Result<String, String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("powercfg.exe")
.args(["-h", "off"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok("休眠模式已关闭hiberfil.sys 已移除。".into())
} else {
Err("执行失败,请确保以管理员身份运行。".into())
}
}

View File

@@ -0,0 +1,128 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::backend::fast_clean::clean_directory_contents;
use crate::backend::models::{
BrowserProfile, BrowserScanResult, BrowserType, CleanResult, ProjectCleanProgress,
};
use crate::backend::utils::{format_size, get_dir_size_simple};
use tauri::Emitter;
const BROWSER_CACHE_DIRS: &[&str] = &[
"Cache",
"Code Cache",
"GPUCache",
"Media Cache",
"Service Worker/CacheStorage",
// "Service Worker/ScriptCache",
// "GrShaderCache",
// "DawnCache",
// "File System",
// "blob_storage",
];
impl BrowserType {
fn get_user_data_path(&self) -> Result<PathBuf, String> {
let local_app_data = std::env::var("LOCALAPPDATA").map_err(|_| "无法获取 LocalAppData 路径")?;
let base = Path::new(&local_app_data);
match self {
BrowserType::Chrome => Ok(base.join("Google\\Chrome\\User Data")),
BrowserType::Edge => Ok(base.join("Microsoft\\Edge\\User Data")),
}
}
}
pub async fn run_browser_scan(browser: BrowserType) -> Result<BrowserScanResult, String> {
let user_data_path = browser.get_user_data_path()?;
let local_state_path = user_data_path.join("Local State");
let mut profiles = Vec::new();
let mut total_bytes = 0;
if local_state_path.exists() {
let content = fs::read_to_string(local_state_path).map_err(|e| e.to_string())?;
let value: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
if let Some(info_cache) = value
.get("profile")
.and_then(|profile| profile.get("info_cache"))
.and_then(|info| info.as_object())
{
for (dir_name, info) in info_cache {
let profile_display_name = info.get("name").and_then(|name| name.as_str()).unwrap_or(dir_name);
let profile_path = user_data_path.join(dir_name);
if profile_path.exists() {
let mut size = 0;
for sub_dir in BROWSER_CACHE_DIRS {
let target = profile_path.join(sub_dir);
if target.exists() {
size += get_dir_size_simple(&target);
}
}
total_bytes += size;
profiles.push(BrowserProfile {
name: profile_display_name.to_string(),
path_name: dir_name.clone(),
cache_size: size,
cache_size_str: format_size(size),
});
}
}
}
}
Ok(BrowserScanResult {
profiles,
total_size: format_size(total_bytes),
})
}
pub async fn run_browser_clean(
browser: BrowserType,
profile_paths: Vec<String>,
app_handle: tauri::AppHandle,
) -> Result<CleanResult, String> {
let user_data_path = browser.get_user_data_path()?;
let mut total_freed = 0;
let mut success_count = 0;
let mut fail_count = 0;
let mut approx_completed_bytes = 0;
let total_items = profile_paths.len() as u32;
for (index, profile_dir) in profile_paths.into_iter().enumerate() {
let profile_path = user_data_path.join(&profile_dir);
let mut profile_estimated_size = 0;
if profile_path.exists() {
for sub_dir in BROWSER_CACHE_DIRS {
let target = profile_path.join(sub_dir);
if target.exists() {
profile_estimated_size += get_dir_size_simple(&target);
let (freed, success, fail) = clean_directory_contents(&target, None);
total_freed += freed;
success_count += success;
fail_count += fail;
}
}
}
approx_completed_bytes += profile_estimated_size;
let _ = app_handle.emit(
"browser-clean-progress",
ProjectCleanProgress {
completed_items: (index + 1) as u32,
total_items,
current_item: profile_dir,
approx_completed_bytes,
},
);
}
Ok(CleanResult {
total_freed: format_size(total_freed),
success_count,
fail_count,
})
}

View File

@@ -0,0 +1,103 @@
use std::collections::HashMap;
use std::fs;
use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::Command;
use tauri::Emitter;
use crate::backend::models::{FileTreeNode, ScanProgress};
use crate::backend::state::DiskState;
use crate::backend::utils::format_size;
pub async fn run_full_scan(root_path: String, state: &DiskState, app_handle: tauri::AppHandle) {
use jwalk::WalkDir;
let mut dir_sizes = HashMap::new();
let root = Path::new(&root_path);
let mut file_count = 0;
for entry in WalkDir::new(root).skip_hidden(false).into_iter().filter_map(|e| e.ok()) {
if entry.file_type.is_file() {
file_count += 1;
if file_count % 2000 == 0 {
let _ = app_handle.emit(
"scan-progress",
ScanProgress {
file_count,
current_path: entry.parent_path().to_string_lossy().to_string(),
},
);
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let mut current_path = entry.parent_path().to_path_buf();
while current_path.starts_with(root) {
let path_str = current_path.to_string_lossy().to_string();
*dir_sizes.entry(path_str).or_insert(0) += size;
if current_path == root {
break;
}
if let Some(parent) = current_path.parent() {
current_path = parent.to_path_buf();
} else {
break;
}
}
}
}
let mut state_dirs = state.dir_sizes.lock().unwrap();
*state_dirs = dir_sizes;
}
pub fn get_children(parent_path: String, state: &DiskState) -> Vec<FileTreeNode> {
let dir_sizes = state.dir_sizes.lock().unwrap();
let mut results = Vec::new();
let parent_size = *dir_sizes.get(&parent_path).unwrap_or(&1);
if let Ok(entries) = fs::read_dir(Path::new(&parent_path)) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let path_str = path.to_string_lossy().to_string();
let is_dir = path.is_dir();
let name = entry.file_name().to_string_lossy().to_string();
let size = if is_dir {
*dir_sizes.get(&path_str).unwrap_or(&0)
} else {
entry.metadata().map(|m| m.len()).unwrap_or(0)
};
if size > 0 || !is_dir {
results.push(FileTreeNode {
name,
path: path_str,
is_dir,
size,
size_str: format_size(size),
percent: (size as f64 / parent_size as f64 * 100.0) as f32,
file_count: 0,
has_children: is_dir,
});
}
}
}
results.sort_by(|a, b| b.size.cmp(&a.size));
results
}
pub async fn open_explorer(path: String) -> Result<(), String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("explorer.exe")
.arg("/select,")
.arg(&path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,184 @@
use std::fs;
use std::path::Path;
use std::time::{Duration, SystemTime};
use tauri::Emitter;
use crate::backend::models::{CleanResult, CleaningConfig, FastScanResult, ProjectCleanProgress, ScanItem};
use crate::backend::utils::format_size;
fn get_fast_cleaning_configs() -> Vec<CleaningConfig> {
let mut configs = Vec::new();
if let Ok(temp) = std::env::var("TEMP") {
configs.push(CleaningConfig::new("用户临时文件", &temp, None, true));
}
configs.push(CleaningConfig::new("系统临时文件", "C:\\Windows\\Temp", None, true));
configs.push(CleaningConfig::new(
"Windows 更新残留",
"C:\\Windows\\SoftwareDistribution\\Download",
Some(10),
true,
));
configs.push(CleaningConfig::new("回收站", "C:\\$Recycle.Bin", None, true));
configs.push(CleaningConfig::new(
"内核转储文件",
"C:\\Windows\\LiveKernelReports",
None,
false,
));
configs
}
pub async fn run_fast_scan() -> FastScanResult {
let configs = get_fast_cleaning_configs();
let mut items = Vec::new();
let mut total_bytes = 0;
let mut total_count = 0;
for config in configs {
let (size, count) = get_dir_stats(Path::new(&config.path), config.filter_days);
items.push(ScanItem {
name: config.name,
path: config.path,
size,
count,
enabled: config.default_enabled,
});
total_bytes += size;
total_count += count;
}
FastScanResult {
items,
total_size: format_size(total_bytes),
total_count,
}
}
fn get_dir_stats(path: &Path, filter_days: Option<u64>) -> (u64, u32) {
if !path.exists() {
return (0, 0);
}
let mut size = 0;
let mut count = 0;
let now = SystemTime::now();
let dur = filter_days.map(|days| Duration::from_secs(days * 24 * 3600));
for entry in walkdir::WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
let mut allowed = true;
if let (Some(filter_duration), Ok(metadata)) = (dur, entry.metadata()) {
if let Ok(modified_time) = metadata.modified() {
if let Ok(elapsed) = now.duration_since(modified_time) {
if elapsed < filter_duration {
allowed = false;
}
}
}
}
if allowed {
size += entry.metadata().map(|m| m.len()).unwrap_or(0);
count += 1;
}
}
}
(size, count)
}
pub async fn run_fast_clean(
selected_paths: Vec<String>,
app_handle: tauri::AppHandle,
) -> Result<CleanResult, String> {
let selected_configs: Vec<CleaningConfig> = get_fast_cleaning_configs()
.into_iter()
.filter(|config| selected_paths.contains(&config.path))
.collect();
let mut success_count = 0;
let mut fail_count = 0;
let mut total_freed = 0;
let mut approx_completed_bytes = 0;
let total_items = selected_configs.len() as u32;
for (index, config) in selected_configs.into_iter().enumerate() {
let path = Path::new(&config.path);
let item_size = get_dir_stats(path, config.filter_days).0;
if path.exists() {
let (freed, success, fail) = clean_directory_contents(path, config.filter_days);
total_freed += freed;
success_count += success;
fail_count += fail;
}
approx_completed_bytes += item_size;
let _ = app_handle.emit(
"fast-clean-progress",
ProjectCleanProgress {
completed_items: (index + 1) as u32,
total_items,
current_item: config.name,
approx_completed_bytes,
},
);
}
Ok(CleanResult {
total_freed: format_size(total_freed),
success_count,
fail_count,
})
}
pub fn clean_directory_contents(path: &Path, filter_days: Option<u64>) -> (u64, u32, u32) {
let mut freed = 0;
let mut success = 0;
let mut fail = 0;
let now = SystemTime::now();
let dur = filter_days.map(|days| Duration::from_secs(days * 24 * 3600));
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.filter_map(|e| e.ok()) {
let entry_path = entry.path();
let metadata = entry.metadata();
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
if let (Some(filter_duration), Ok(metadata)) = (dur, &metadata) {
if let Ok(modified_time) = metadata.modified() {
if let Ok(elapsed) = now.duration_since(modified_time) {
if elapsed < filter_duration {
continue;
}
}
}
}
if entry_path.is_file() {
if fs::remove_file(&entry_path).is_ok() {
freed += size;
success += 1;
} else {
fail += 1;
}
} else if entry_path.is_dir() {
let (dir_freed, dir_success, dir_fail) =
clean_directory_contents(&entry_path, filter_days);
freed += dir_freed;
success += dir_success;
fail += dir_fail;
if fs::remove_dir(&entry_path).is_ok() {
success += 1;
}
}
}
}
(freed, success, fail)
}

View File

@@ -0,0 +1,62 @@
use sysinfo::{ProcessesToUpdate, System};
use crate::backend::models::MemoryStats;
pub fn get_memory_stats() -> MemoryStats {
let mut sys = System::new_all();
sys.refresh_memory();
let total = sys.total_memory();
let used = sys.used_memory();
let free = total.saturating_sub(used);
let percent = (used as f32 / total as f32) * 100.0;
MemoryStats {
total,
used,
free,
percent,
}
}
pub async fn run_memory_clean() -> Result<u64, String> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::ProcessStatus::EmptyWorkingSet;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_SET_QUOTA,
};
let before = get_memory_stats().used;
let mut sys = System::new_all();
sys.refresh_processes(ProcessesToUpdate::All, true);
for (pid, _) in sys.processes() {
let pid_u32 = pid.as_u32();
unsafe {
let handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SET_QUOTA, 0, pid_u32);
if handle != std::ptr::null_mut() {
EmptyWorkingSet(handle);
CloseHandle(handle);
}
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let after = get_memory_stats().used;
Ok(before.saturating_sub(after))
}
pub async fn run_deep_memory_clean() -> Result<u64, String> {
use windows_sys::Win32::System::Memory::SetSystemFileCacheSize;
let before = get_memory_stats().used;
unsafe {
SetSystemFileCacheSize(usize::MAX, usize::MAX, 0);
}
let after = get_memory_stats().used;
Ok(before.saturating_sub(after))
}

View File

@@ -0,0 +1,8 @@
pub mod advanced_clean;
pub mod browser_clean;
pub mod disk_analysis;
pub mod fast_clean;
pub mod memory_clean;
pub mod models;
pub mod state;
pub mod utils;

View File

@@ -0,0 +1,96 @@
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct FileTreeNode {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
pub size_str: String,
pub percent: f32,
pub file_count: u32,
pub has_children: bool,
}
#[derive(Serialize, Clone)]
pub struct ScanProgress {
pub file_count: u64,
pub current_path: String,
}
#[derive(Serialize, Clone)]
pub struct ProjectCleanProgress {
pub completed_items: u32,
pub total_items: u32,
pub current_item: String,
pub approx_completed_bytes: u64,
}
#[derive(Clone)]
pub struct CleaningConfig {
pub name: String,
pub path: String,
pub filter_days: Option<u64>,
pub default_enabled: bool,
}
impl CleaningConfig {
pub fn new(name: &str, path: &str, filter_days: Option<u64>, default_enabled: bool) -> Self {
Self {
name: name.into(),
path: path.into(),
filter_days,
default_enabled,
}
}
}
#[derive(Serialize, Clone)]
pub struct ScanItem {
pub name: String,
pub path: String,
pub size: u64,
pub count: u32,
pub enabled: bool,
}
#[derive(Serialize)]
pub struct FastScanResult {
pub items: Vec<ScanItem>,
pub total_size: String,
pub total_count: u32,
}
#[derive(Serialize)]
pub struct CleanResult {
pub total_freed: String,
pub success_count: u32,
pub fail_count: u32,
}
#[derive(Serialize, Clone)]
pub struct BrowserProfile {
pub name: String,
pub path_name: String,
pub cache_size: u64,
pub cache_size_str: String,
}
#[derive(Serialize)]
pub struct BrowserScanResult {
pub profiles: Vec<BrowserProfile>,
pub total_size: String,
}
pub enum BrowserType {
Chrome,
Edge,
}
#[derive(Serialize, Clone)]
pub struct MemoryStats {
pub total: u64,
pub used: u64,
pub free: u64,
pub percent: f32,
}

View File

@@ -0,0 +1,6 @@
use std::collections::HashMap;
use std::sync::Mutex;
pub struct DiskState {
pub dir_sizes: Mutex<HashMap<String, u64>>,
}

View File

@@ -0,0 +1,26 @@
use std::path::Path;
pub fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB {
format!("{:.2} GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.2} MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.2} KB", size as f64 / KB as f64)
} else {
format!("{} B", size)
}
}
pub fn get_dir_size_simple(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum()
}

View File

@@ -1,565 +0,0 @@
use std::fs;
use std::path::Path;
use std::time::{SystemTime, Duration};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Mutex;
use std::process::Command;
use std::os::windows::process::CommandExt;
// 存储全盘扫描后的结果
pub struct DiskState {
pub dir_sizes: Mutex<HashMap<String, u64>>,
// pub file_info: Mutex<HashMap<String, (u64, u32)>>,
}
#[derive(Serialize, Clone)]
pub struct FileTreeNode {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
pub size_str: String,
pub percent: f32,
pub file_count: u32,
pub has_children: bool,
}
pub fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB { format!("{:.2} GB", size as f64 / GB as f64) }
else if size >= MB { format!("{:.2} MB", size as f64 / MB as f64) }
else if size >= KB { format!("{:.2} KB", size as f64 / KB as f64) }
else { format!("{} B", size) }
}
// --- 高级清理功能实现 ---
// 1. 系统组件清理 (DISM)
pub async fn run_dism_cleanup() -> Result<String, String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("dism.exe")
.args(&["/online", "/Cleanup-Image", "/StartComponentCleanup"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok("系统组件清理完成。".into())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
// 2. 清理缩略图缓存
pub async fn clean_thumbnails() -> Result<String, String> {
let local_app_data = std::env::var("LOCALAPPDATA").map_err(|_| "无法获取 LocalAppData 路径")?;
let thumb_path = Path::new(&local_app_data).join("Microsoft\\Windows\\Explorer");
let mut count = 0;
if thumb_path.exists() {
if let Ok(entries) = fs::read_dir(thumb_path) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_lowercase();
if name.starts_with("thumbcache_") && name.ends_with(".db") {
// 缩略图文件通常被 Explorer 占用,这里尝试删除,失败也继续
if fs::remove_file(entry.path()).is_ok() {
count += 1;
}
}
}
}
}
if count > 0 {
Ok(format!("成功清理 {} 个缩略图缓存文件。", count))
} else {
Ok("未发现可清理的缩略图缓存,或文件正被系统占用。".into())
}
}
// 3. 关闭休眠文件
pub async fn disable_hibernation() -> Result<String, String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("powercfg.exe")
.args(&["-h", "off"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok("休眠模式已关闭hiberfil.sys 已移除。".into())
} else {
Err("执行失败,请确保以管理员身份运行。".into())
}
}
// --- 原有逻辑保持 (磁盘树等) ---
#[derive(Serialize, Clone)]
pub struct ScanProgress {
pub file_count: u64,
pub current_path: String,
}
pub async fn run_full_scan(root_path: String, state: &DiskState, app_handle: tauri::AppHandle) {
use jwalk::WalkDir;
use tauri::Emitter;
let mut dir_sizes = HashMap::new();
let root = Path::new(&root_path);
let mut file_count = 0;
for entry in WalkDir::new(root).skip_hidden(false).into_iter().filter_map(|e| e.ok()) {
if entry.file_type.is_file() {
file_count += 1;
// 节流推送进度:每 2000 个文件推送一次,避免 IPC 过载
if file_count % 2000 == 0 {
let _ = app_handle.emit("scan-progress", ScanProgress {
file_count,
current_path: entry.parent_path().to_string_lossy().to_string(),
});
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let mut current_path = entry.parent_path().to_path_buf();
while current_path.starts_with(root) {
let path_str = current_path.to_string_lossy().to_string();
*dir_sizes.entry(path_str).or_insert(0) += size;
if current_path == root { break; }
if let Some(parent) = current_path.parent() { current_path = parent.to_path_buf(); } else { break; }
}
}
}
let mut state_dirs = state.dir_sizes.lock().unwrap();
*state_dirs = dir_sizes;
}
pub fn get_children(parent_path: String, state: &DiskState) -> Vec<FileTreeNode> {
let dir_sizes = state.dir_sizes.lock().unwrap();
let mut results = Vec::new();
let parent_size = *dir_sizes.get(&parent_path).unwrap_or(&1);
if let Ok(entries) = fs::read_dir(Path::new(&parent_path)) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let path_str = path.to_string_lossy().to_string();
let is_dir = path.is_dir();
let name = entry.file_name().to_string_lossy().to_string();
let size = if is_dir {
*dir_sizes.get(&path_str).unwrap_or(&0)
} else {
entry.metadata().map(|m| m.len()).unwrap_or(0)
};
if size > 0 || !is_dir {
results.push(FileTreeNode {
name,
path: path_str,
is_dir,
size,
size_str: format_size(size),
percent: (size as f64 / parent_size as f64 * 100.0) as f32,
file_count: 0,
has_children: is_dir,
});
}
}
}
results.sort_by(|a, b| b.size.cmp(&a.size));
results
}
pub async fn open_explorer(path: String) -> Result<(), String> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
// 使用 /select, 参数可以在打开目录的同时选中目标
Command::new("explorer.exe")
.arg("/select,")
.arg(&path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}
// --- 快速模式配置与逻辑 ---
#[derive(Clone)]
pub struct CleaningConfig {
pub name: String,
pub path: String,
pub filter_days: Option<u64>,
pub default_enabled: bool,
}
impl CleaningConfig {
fn new(name: &str, path: &str, filter_days: Option<u64>, default_enabled: bool) -> Self {
Self { name: name.into(), path: path.into(), filter_days, default_enabled }
}
}
/// 获取当前所有快速清理项的配置
fn get_fast_cleaning_configs() -> Vec<CleaningConfig> {
let mut configs = Vec::new();
// 1. 用户临时文件
if let Ok(t) = std::env::var("TEMP") {
configs.push(CleaningConfig::new("用户临时文件", &t, None, true));
}
// 2. 系统临时文件
configs.push(CleaningConfig::new("系统临时文件", "C:\\Windows\\Temp", None, true));
// 3. Windows 更新残留 (通常建议清理 10 天前的)
configs.push(CleaningConfig::new("Windows 更新残留", "C:\\Windows\\SoftwareDistribution\\Download", Some(10), true));
// 4. 内核转储文件
configs.push(CleaningConfig::new("内核转储文件", "C:\\Windows\\LiveKernelReports", None, false));
configs
}
#[derive(Serialize, Clone)]
pub struct ScanItem {
pub name: String,
pub path: String,
pub size: u64,
pub count: u32,
pub enabled: bool,
}
#[derive(Serialize)]
pub struct FastScanResult { pub items: Vec<ScanItem>, total_size: String, total_count: u32 }
pub async fn run_fast_scan() -> FastScanResult {
let configs = get_fast_cleaning_configs();
let mut items = Vec::new();
let mut total_bytes = 0;
let mut total_count = 0;
for config in configs {
let (size, count) = get_dir_stats(Path::new(&config.path), config.filter_days);
items.push(ScanItem {
name: config.name,
path: config.path,
size,
count,
enabled: config.default_enabled,
});
total_bytes += size;
total_count += count;
}
FastScanResult {
items,
total_size: format_size(total_bytes),
total_count
}
}
fn get_dir_stats(path: &Path, filter_days: Option<u64>) -> (u64, u32) {
if !path.exists() { return (0, 0); }
let mut size = 0;
let mut count = 0;
let now = SystemTime::now();
let dur = filter_days.map(|d| Duration::from_secs(d * 24 * 3600));
for entry in walkdir::WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
let mut ok = true;
if let (Some(d), Ok(m)) = (dur, entry.metadata()) {
if let Ok(mod_t) = m.modified() {
if let Ok(el) = now.duration_since(mod_t) {
if el < d { ok = false; }
}
}
}
if ok {
size += entry.metadata().map(|m| m.len()).unwrap_or(0);
count += 1;
}
}
}
(size, count)
}
#[derive(Serialize)]
pub struct CleanResult {
pub total_freed: String,
pub success_count: u32,
pub fail_count: u32,
}
pub async fn run_fast_clean(selected_paths: Vec<String>) -> Result<CleanResult, String> {
let configs = get_fast_cleaning_configs();
let mut success_count = 0;
let mut fail_count = 0;
let mut total_freed: u64 = 0;
for config in configs {
if selected_paths.contains(&config.path) {
let path = Path::new(&config.path);
if path.exists() {
let (freed, s, f) = clean_directory_contents(path, config.filter_days);
total_freed += freed;
success_count += s;
fail_count += f;
}
}
}
Ok(CleanResult {
total_freed: format_size(total_freed),
success_count,
fail_count,
})
}
fn clean_directory_contents(path: &Path, filter_days: Option<u64>) -> (u64, u32, u32) {
let mut freed = 0;
let mut success = 0;
let mut fail = 0;
let now = SystemTime::now();
let dur = filter_days.map(|d| Duration::from_secs(d * 24 * 3600));
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.filter_map(|e| e.ok()) {
let entry_path = entry.path();
let metadata = entry.metadata();
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
// 检查过滤逻辑 (如果设置了天数)
if let (Some(d), Ok(m)) = (dur, &metadata) {
if let Ok(mod_t) = m.modified() {
if let Ok(el) = now.duration_since(mod_t) {
if el < d { continue; }
}
}
}
if entry_path.is_file() {
if fs::remove_file(&entry_path).is_ok() {
freed += size;
success += 1;
} else {
fail += 1;
}
} else if entry_path.is_dir() {
// 递归清理子目录
let (f, s, fl) = clean_directory_contents(&entry_path, filter_days);
freed += f;
success += s;
fail += fl;
// 尝试删除已清空的目录 (如果它本身不是根清理目录且已过期)
if fs::remove_dir(&entry_path).is_ok() {
success += 1;
} else {
// 目录可能因为包含未过期的文件而无法删除,这是正常的
}
}
}
}
(freed, success, fail)
}
// --- 浏览器清理逻辑 ---
const BROWSER_CACHE_DIRS: &[&str] = &[
"Cache",
"Code Cache",
"GPUCache",
"Media Cache",
"Service Worker/CacheStorage",
"Service Worker/ScriptCache",
"GrShaderCache",
"DawnCache",
"File System",
"blob_storage"
];
#[derive(Serialize, Clone)]
pub struct BrowserProfile {
pub name: String,
pub path_name: String,
pub cache_size: u64,
pub cache_size_str: String,
}
#[derive(Serialize)]
pub struct BrowserScanResult {
pub profiles: Vec<BrowserProfile>,
pub total_size: String,
}
pub enum BrowserType {
Chrome,
Edge,
}
impl BrowserType {
fn get_user_data_path(&self) -> Result<std::path::PathBuf, String> {
let local_app_data = std::env::var("LOCALAPPDATA").map_err(|_| "无法获取 LocalAppData 路径")?;
let base = std::path::Path::new(&local_app_data);
match self {
BrowserType::Chrome => Ok(base.join("Google\\Chrome\\User Data")),
BrowserType::Edge => Ok(base.join("Microsoft\\Edge\\User Data")),
}
}
}
pub async fn run_browser_scan(browser: BrowserType) -> Result<BrowserScanResult, String> {
let user_data_path = browser.get_user_data_path()?;
let local_state_path = user_data_path.join("Local State");
let mut profiles = Vec::new();
let mut total_bytes = 0;
if local_state_path.exists() {
let content = fs::read_to_string(local_state_path).map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
if let Some(info_cache) = v.get("profile").and_then(|p| p.get("info_cache")).and_then(|i| i.as_object()) {
for (dir_name, info) in info_cache {
let profile_display_name = info.get("name").and_then(|n| n.as_str()).unwrap_or(dir_name);
let profile_path = user_data_path.join(dir_name);
if profile_path.exists() {
let mut size = 0;
// 扫描配置的缓存目录
for sub in BROWSER_CACHE_DIRS {
let target = profile_path.join(sub);
if target.exists() {
size += get_dir_size_simple(&target);
}
}
total_bytes += size;
profiles.push(BrowserProfile {
name: profile_display_name.to_string(),
path_name: dir_name.clone(),
cache_size: size,
cache_size_str: format_size(size),
});
}
}
}
}
Ok(BrowserScanResult {
profiles,
total_size: format_size(total_bytes),
})
}
fn get_dir_size_simple(path: &std::path::Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum()
}
pub async fn run_browser_clean(browser: BrowserType, profile_paths: Vec<String>) -> Result<CleanResult, String> {
let user_data_path = browser.get_user_data_path()?;
let mut total_freed = 0;
let mut success_count = 0;
let mut fail_count = 0;
for profile_dir in profile_paths {
let profile_path = user_data_path.join(&profile_dir);
if profile_path.exists() {
// 清理配置的缓存目录
for sub in BROWSER_CACHE_DIRS {
let target = profile_path.join(sub);
if target.exists() {
let (f, s, fl) = clean_directory_contents(&target, None);
total_freed += f;
success_count += s;
fail_count += fl;
}
}
}
}
Ok(CleanResult {
total_freed: format_size(total_freed),
success_count,
fail_count,
})
}
// --- 内存清理逻辑 ---
use sysinfo::{System, ProcessesToUpdate};
#[derive(Serialize, Clone)]
pub struct MemoryStats {
pub total: u64,
pub used: u64,
pub free: u64,
pub percent: f32,
}
/// 获取当前系统内存状态
pub fn get_memory_stats() -> MemoryStats {
let mut sys = System::new_all();
sys.refresh_memory();
let total = sys.total_memory();
let used = sys.used_memory();
let free = total.saturating_sub(used);
let percent = (used as f32 / total as f32) * 100.0;
MemoryStats { total, used, free, percent }
}
/// 执行内存压缩 (Empty Working Set)
pub async fn run_memory_clean() -> Result<u64, String> {
use windows_sys::Win32::System::ProcessStatus::EmptyWorkingSet;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_SET_QUOTA};
use windows_sys::Win32::Foundation::CloseHandle;
let before = get_memory_stats().used;
let mut sys = System::new_all();
sys.refresh_processes(ProcessesToUpdate::All, true);
for (pid, _) in sys.processes() {
let pid_u32 = pid.as_u32();
unsafe {
let handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SET_QUOTA, 0, pid_u32);
if handle != std::ptr::null_mut() {
EmptyWorkingSet(handle);
CloseHandle(handle);
}
}
}
// 给系统一点点时间反应
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let after = get_memory_stats().used;
let freed = before.saturating_sub(after);
Ok(freed)
}
/// 深度内存清理 (Standby List / System Cache)
pub async fn run_deep_memory_clean() -> Result<u64, String> {
use windows_sys::Win32::System::Memory::SetSystemFileCacheSize;
let before = get_memory_stats().used;
unsafe {
// -1 (usize::MAX) 表示清空系统文件缓存
SetSystemFileCacheSize(usize::MAX, usize::MAX, 0);
}
let after = get_memory_stats().used;
let freed = before.saturating_sub(after);
Ok(freed)
}

View File

@@ -1,89 +1,106 @@
mod cleaner;
use tauri::State;
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::State;
mod backend;
#[tauri::command]
async fn start_fast_scan() -> cleaner::FastScanResult {
cleaner::run_fast_scan().await
async fn start_fast_scan() -> backend::models::FastScanResult {
backend::fast_clean::run_fast_scan().await
}
#[tauri::command]
async fn start_fast_clean(selected_paths: Vec<String>) -> Result<cleaner::CleanResult, String> {
cleaner::run_fast_clean(selected_paths).await
async fn start_fast_clean(
selected_paths: Vec<String>,
app_handle: tauri::AppHandle,
) -> Result<backend::models::CleanResult, String> {
backend::fast_clean::run_fast_clean(selected_paths, app_handle).await
}
#[tauri::command]
async fn start_full_disk_scan(state: State<'_, cleaner::DiskState>, app_handle: tauri::AppHandle) -> Result<(), String> {
cleaner::run_full_scan("C:\\".to_string(), &state, app_handle).await;
async fn start_full_disk_scan(
state: State<'_, backend::state::DiskState>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
backend::disk_analysis::run_full_scan("C:\\".to_string(), &state, app_handle).await;
Ok(())
}
#[tauri::command]
async fn get_tree_children(path: String, state: State<'_, cleaner::DiskState>) -> Result<Vec<cleaner::FileTreeNode>, String> {
Ok(cleaner::get_children(path, &state))
async fn get_tree_children(
path: String,
state: State<'_, backend::state::DiskState>,
) -> Result<Vec<backend::models::FileTreeNode>, String> {
Ok(backend::disk_analysis::get_children(path, &state))
}
#[tauri::command]
async fn open_in_explorer(path: String) -> Result<(), String> {
cleaner::open_explorer(path).await
backend::disk_analysis::open_explorer(path).await
}
// --- 高级清理命令 ---
#[tauri::command]
async fn clean_system_components() -> Result<String, String> {
cleaner::run_dism_cleanup().await
backend::advanced_clean::run_dism_cleanup().await
}
#[tauri::command]
async fn clean_thumbnails() -> Result<String, String> {
cleaner::clean_thumbnails().await
backend::advanced_clean::clean_thumbnails().await
}
#[tauri::command]
async fn disable_hibernation() -> Result<String, String> {
cleaner::disable_hibernation().await
}
// --- 浏览器清理命令 ---
#[tauri::command]
async fn start_browser_scan(browser: String) -> Result<cleaner::BrowserScanResult, String> {
let b_type = if browser == "chrome" { cleaner::BrowserType::Chrome } else { cleaner::BrowserType::Edge };
cleaner::run_browser_scan(b_type).await
backend::advanced_clean::disable_hibernation().await
}
#[tauri::command]
async fn start_browser_clean(browser: String, profiles: Vec<String>) -> Result<cleaner::CleanResult, String> {
let b_type = if browser == "chrome" { cleaner::BrowserType::Chrome } else { cleaner::BrowserType::Edge };
cleaner::run_browser_clean(b_type, profiles).await
async fn start_browser_scan(browser: String) -> Result<backend::models::BrowserScanResult, String> {
let browser_type = if browser == "chrome" {
backend::models::BrowserType::Chrome
} else {
backend::models::BrowserType::Edge
};
backend::browser_clean::run_browser_scan(browser_type).await
}
// --- 内存清理命令 ---
#[tauri::command]
async fn start_browser_clean(
browser: String,
profiles: Vec<String>,
app_handle: tauri::AppHandle,
) -> Result<backend::models::CleanResult, String> {
let browser_type = if browser == "chrome" {
backend::models::BrowserType::Chrome
} else {
backend::models::BrowserType::Edge
};
backend::browser_clean::run_browser_clean(browser_type, profiles, app_handle).await
}
#[tauri::command]
async fn get_memory_stats() -> cleaner::MemoryStats {
cleaner::get_memory_stats()
async fn get_memory_stats() -> backend::models::MemoryStats {
backend::memory_clean::get_memory_stats()
}
#[tauri::command]
async fn run_memory_clean() -> Result<u64, String> {
cleaner::run_memory_clean().await
backend::memory_clean::run_memory_clean().await
}
#[tauri::command]
async fn run_deep_memory_clean() -> Result<u64, String> {
cleaner::run_deep_memory_clean().await
backend::memory_clean::run_deep_memory_clean().await
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(cleaner::DiskState {
.manage(backend::state::DiskState {
dir_sizes: Mutex::new(HashMap::new()),
// file_info: Mutex::new(HashMap::new()),
})
.invoke_handler(tauri::generate_handler![
start_fast_scan,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "win-cleaner",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "top.volan.win-cleaner",
"build": {
"beforeDevCommand": "pnpm dev",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { ModalMode, ModalType } from "../../types/cleaner";
defineProps<{
open: boolean;
title: string;
message: string;
type: ModalType;
mode?: ModalMode;
confirmText?: string;
cancelText?: string;
}>();
const emit = defineEmits<{
close: [];
confirm: [];
}>();
</script>
<template>
<div v-if="open" class="modal-overlay" @click.self="emit('close')">
<div class="modal-card" :class="type">
<div class="modal-header">
<span class="modal-icon">
<template v-if="type === 'success'"></template>
<template v-else-if="type === 'error'"></template>
<template v-else></template>
</span>
<h3>{{ title }}</h3>
</div>
<div class="modal-body">
<p>{{ message }}</p>
</div>
<div class="modal-footer" :class="{ 'is-confirm': mode === 'confirm' }">
<button v-if="mode === 'confirm'" class="btn-secondary modal-btn" @click="emit('close')">
{{ cancelText || "取消" }}
</button>
<button class="btn-primary modal-btn" @click="mode === 'confirm' ? emit('confirm') : emit('close')">
{{ mode === "confirm" ? confirmText || "确定清理" : confirmText || "确定" }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import pkg from "../../../package.json";
import type { Tab } from "../../types/cleaner";
defineProps<{
activeTab: Tab;
isCMenuOpen: boolean;
isBrowserMenuOpen: boolean;
}>();
const emit = defineEmits<{
"update:activeTab": [tab: Tab];
"toggle-c-menu": [];
"toggle-browser-menu": [];
}>();
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h2 class="brand">Windows 清理工具</h2>
</div>
<nav class="sidebar-nav">
<div class="nav-group">
<div class="nav-item-header" @click="emit('toggle-c-menu')">
<span class="icon svg-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</span>
<span class="label">清理 C </span>
<span class="arrow" :class="{ open: isCMenuOpen }"></span>
</div>
<div class="nav-sub-items" v-show="isCMenuOpen">
<div class="nav-sub-item" :class="{ active: activeTab === 'clean-c-fast' }" @click="emit('update:activeTab', 'clean-c-fast')">
快速模式
</div>
<div class="nav-sub-item" :class="{ active: activeTab === 'clean-c-advanced' }" @click="emit('update:activeTab', 'clean-c-advanced')">
高级模式
</div>
<div class="nav-sub-item" :class="{ active: activeTab === 'clean-c-deep' }" @click="emit('update:activeTab', 'clean-c-deep')">
查找大目录
</div>
</div>
</div>
<div class="nav-group">
<div class="nav-item-header" @click="emit('toggle-browser-menu')">
<span class="icon svg-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M7 7h.01"/><path d="M11 7h.01"/></svg>
</span>
<span class="label">清理浏览器</span>
<span class="arrow" :class="{ open: isBrowserMenuOpen }"></span>
</div>
<div class="nav-sub-items" v-show="isBrowserMenuOpen">
<div class="nav-sub-item" :class="{ active: activeTab === 'clean-browser-chrome' }" @click="emit('update:activeTab', 'clean-browser-chrome')">
谷歌浏览器
</div>
<div class="nav-sub-item" :class="{ active: activeTab === 'clean-browser-edge' }" @click="emit('update:activeTab', 'clean-browser-edge')">
微软浏览器
</div>
</div>
</div>
<div class="nav-item" :class="{ active: activeTab === 'clean-memory' }" @click="emit('update:activeTab', 'clean-memory')">
<span class="icon svg-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</span>
<span class="label">清理内存</span>
</div>
</nav>
<div class="sidebar-footer">
<span class="version">v{{ pkg.version }}</span>
</div>
</aside>
</template>

View File

@@ -0,0 +1,48 @@
import { ref } from "vue";
import {
cleanSystemComponents,
cleanThumbnails,
disableHibernation,
} from "../services/tauri/cleaner";
import type { AlertOptions } from "../types/cleaner";
export function useAdvancedClean(showAlert: (options: AlertOptions) => void) {
const expandedAdvanced = ref<string | null>(null);
const loading = ref<Record<string, boolean>>({});
async function runTask(task: string) {
loading.value[task] = true;
try {
let title = "";
let result = "";
if (task === "dism") {
title = "系统组件清理";
result = await cleanSystemComponents();
} else if (task === "thumb") {
title = "缩略图清理";
result = await cleanThumbnails();
} else if (task === "hiber") {
title = "休眠文件优化";
result = await disableHibernation();
}
showAlert({ title, message: result, type: "success" });
} catch (err) {
showAlert({
title: "任务失败",
message: String(err),
type: "error",
});
} finally {
loading.value[task] = false;
}
}
return {
expandedAdvanced,
loading,
runTask,
};
}

View File

@@ -0,0 +1,197 @@
import { computed, ref } from "vue";
import {
startBrowserClean as runBrowserCleanCommand,
startBrowserScan as runBrowserScanCommand,
subscribeBrowserCleanProgress,
} from "../services/tauri/cleaner";
import type {
AlertOptions,
BrowserScanResult,
CleanResult,
ConfirmOptions,
ProjectCleanProgressPayload,
} from "../types/cleaner";
import { formatItemSize } from "../utils/format";
interface BrowserState {
isScanning: boolean;
isCleaning: boolean;
isDone: boolean;
scanResult: BrowserScanResult | null;
cleanResult: CleanResult | null;
}
interface BrowserCleanProgressState {
completedItems: number;
totalItems: number;
currentItem: string;
approxCompletedBytes: number;
}
export function useBrowserClean(
browser: "chrome" | "edge",
showAlert: (options: AlertOptions) => void,
requestConfirm: (options: ConfirmOptions) => Promise<boolean>,
) {
const state = ref<BrowserState>({
isScanning: false,
isCleaning: false,
isDone: false,
scanResult: null,
cleanResult: null,
});
const cleanProgress = ref<BrowserCleanProgressState>({
completedItems: 0,
totalItems: 0,
currentItem: "",
approxCompletedBytes: 0,
});
const selectedStats = computed(() => {
const scanResult = state.value.scanResult;
if (!scanResult) return { totalBytes: 0, sizeStr: "0 B", count: 0, hasSelection: false };
const enabledProfiles = scanResult.profiles.filter((profile) => profile.enabled);
const totalBytes = enabledProfiles.reduce((acc, profile) => acc + profile.cache_size, 0);
return {
totalBytes,
sizeStr: formatItemSize(totalBytes),
count: enabledProfiles.length,
hasSelection: enabledProfiles.length > 0,
};
});
const cleanProgressSizeStr = computed(() => formatSizeValue(cleanProgress.value.approxCompletedBytes));
function formatSizeValue(bytes: number) {
return formatItemSize(bytes);
}
function resetCleanProgress() {
cleanProgress.value = {
completedItems: 0,
totalItems: 0,
currentItem: "",
approxCompletedBytes: 0,
};
}
function handleCleanProgress(payload: ProjectCleanProgressPayload) {
cleanProgress.value = {
completedItems: payload.completed_items,
totalItems: payload.total_items,
currentItem: payload.current_item,
approxCompletedBytes: payload.approx_completed_bytes,
};
}
async function startScan() {
const current = state.value;
current.isScanning = true;
current.isDone = false;
current.scanResult = null;
current.cleanResult = null;
try {
const result = await runBrowserScanCommand(browser);
current.scanResult = {
...result,
profiles: result.profiles
.map((profile) => ({ ...profile, enabled: true }))
.sort((a, b) => b.cache_size - a.cache_size),
};
} catch (err) {
showAlert({
title: "扫描失败",
message: String(err),
type: "error",
});
} finally {
current.isScanning = false;
}
}
async function startClean() {
const current = state.value;
if (!current.scanResult || current.isCleaning) return;
const selectedProfiles = current.scanResult.profiles
.filter((profile) => profile.enabled)
.map((profile) => profile.path_name);
if (selectedProfiles.length === 0) {
showAlert({
title: "未选择",
message: "请选择至少一个用户资料进行清理。",
type: "info",
});
return;
}
const browserName = browser === "chrome" ? "谷歌浏览器" : "微软浏览器";
const confirmed = await requestConfirm({
title: "确认清理浏览器缓存",
message: `即将清理 ${browserName} 的缓存和临时文件。\n\n建议先关闭浏览器以避免部分文件被占用导致清理不完整。\n\n是否继续`,
type: "info",
confirmText: "继续清理",
cancelText: "暂不清理",
});
if (!confirmed) {
return;
}
resetCleanProgress();
current.isCleaning = true;
const unlisten = await subscribeBrowserCleanProgress(handleCleanProgress);
try {
current.cleanResult = await runBrowserCleanCommand(browser, selectedProfiles);
current.isDone = true;
current.scanResult = null;
} catch (err) {
showAlert({
title: "清理失败",
message: String(err),
type: "error",
});
} finally {
unlisten();
current.isCleaning = false;
}
}
function toggleAllProfiles(enabled: boolean) {
state.value.scanResult?.profiles.forEach((profile) => {
profile.enabled = enabled;
});
}
function invertProfiles() {
state.value.scanResult?.profiles.forEach((profile) => {
profile.enabled = !profile.enabled;
});
}
function reset() {
state.value = {
isScanning: false,
isCleaning: false,
isDone: false,
scanResult: null,
cleanResult: null,
};
resetCleanProgress();
}
return {
state,
selectedStats,
cleanProgress,
cleanProgressSizeStr,
startScan,
startClean,
toggleAllProfiles,
invertProfiles,
reset,
};
}

View File

@@ -0,0 +1,89 @@
import { ref } from "vue";
import {
getTreeChildren,
startFullDiskScan as runFullDiskScanCommand,
subscribeScanProgress,
} from "../services/tauri/cleaner";
import type { AlertOptions, FileNode } from "../types/cleaner";
export function useDiskAnalysis(showAlert: (options: AlertOptions) => void) {
const isFullScanning = ref(false);
const fullScanProgress = ref({ fileCount: 0, currentPath: "" });
const treeData = ref<FileNode[]>([]);
async function startFullDiskScan() {
isFullScanning.value = true;
treeData.value = [];
fullScanProgress.value = { fileCount: 0, currentPath: "" };
const unlisten = await subscribeScanProgress((payload) => {
fullScanProgress.value.fileCount = payload.file_count;
fullScanProgress.value.currentPath = payload.current_path;
});
try {
await runFullDiskScanCommand();
const rootChildren = await getTreeChildren("C:\\");
treeData.value = rootChildren.map((node) => ({
...node,
level: 0,
isOpen: false,
isLoading: false,
}));
} catch {
showAlert({
title: "扫描失败",
message: "请确保以管理员身份运行程序。",
type: "error",
});
} finally {
isFullScanning.value = false;
unlisten();
}
}
async function toggleNode(index: number) {
const node = treeData.value[index];
if (!node?.is_dir || node.isLoading) return;
if (node.isOpen) {
let removeCount = 0;
for (let i = index + 1; i < treeData.value.length; i += 1) {
if (treeData.value[i].level > node.level) removeCount += 1;
else break;
}
treeData.value.splice(index + 1, removeCount);
node.isOpen = false;
return;
}
node.isLoading = true;
try {
const children = await getTreeChildren(node.path);
const mappedChildren = children.map((child) => ({
...child,
level: node.level + 1,
isOpen: false,
isLoading: false,
}));
treeData.value.splice(index + 1, 0, ...mappedChildren);
node.isOpen = true;
} catch (err) {
showAlert({
title: "展开失败",
message: String(err),
type: "error",
});
} finally {
node.isLoading = false;
}
}
return {
isFullScanning,
fullScanProgress,
treeData,
startFullDiskScan,
toggleNode,
};
}

View File

@@ -0,0 +1,185 @@
import { computed, ref } from "vue";
import {
startFastClean as runFastCleanCommand,
startFastScan as runFastScanCommand,
subscribeFastCleanProgress,
} from "../services/tauri/cleaner";
import type {
AlertOptions,
CleanResult,
ConfirmOptions,
FastScanResult,
ProjectCleanProgressPayload,
} from "../types/cleaner";
import { formatItemSize } from "../utils/format";
interface FastState {
isScanning: boolean;
isCleaning: boolean;
isDone: boolean;
progress: number;
scanResult: FastScanResult | null;
cleanResult: CleanResult | null;
}
interface FastCleanProgressState {
completedItems: number;
totalItems: number;
currentItem: string;
approxCompletedBytes: number;
}
export function useFastClean(
showAlert: (options: AlertOptions) => void,
requestConfirm: (options: ConfirmOptions) => Promise<boolean>,
) {
const state = ref<FastState>({
isScanning: false,
isCleaning: false,
isDone: false,
progress: 0,
scanResult: null,
cleanResult: null,
});
const cleanProgress = ref<FastCleanProgressState>({
completedItems: 0,
totalItems: 0,
currentItem: "",
approxCompletedBytes: 0,
});
const selectedStats = computed(() => {
const scanResult = state.value.scanResult;
if (!scanResult) return { totalBytes: 0, sizeStr: "0 B", count: 0, hasSelection: false };
const enabledItems = scanResult.items.filter((item) => item.enabled);
const totalBytes = enabledItems.reduce((acc, item) => acc + item.size, 0);
const totalCount = enabledItems.reduce((acc, item) => acc + item.count, 0);
return {
totalBytes,
sizeStr: formatItemSize(totalBytes),
count: totalCount,
hasSelection: enabledItems.length > 0,
};
});
const cleanProgressSizeStr = computed(() => formatItemSize(cleanProgress.value.approxCompletedBytes));
function resetCleanProgress() {
cleanProgress.value = {
completedItems: 0,
totalItems: 0,
currentItem: "",
approxCompletedBytes: 0,
};
}
function handleCleanProgress(payload: ProjectCleanProgressPayload) {
cleanProgress.value = {
completedItems: payload.completed_items,
totalItems: payload.total_items,
currentItem: payload.current_item,
approxCompletedBytes: payload.approx_completed_bytes,
};
}
async function startScan() {
const current = state.value;
current.isScanning = true;
current.isDone = false;
current.progress = 0;
current.scanResult = null;
const interval = window.setInterval(() => {
if (current.progress < 95) {
current.progress += Math.floor(Math.random() * 5);
}
}, 100);
try {
current.scanResult = await runFastScanCommand();
current.progress = 100;
} catch {
showAlert({
title: "扫描失败",
message: "请尝试以管理员身份运行程序。",
type: "error",
});
} finally {
window.clearInterval(interval);
current.isScanning = false;
}
}
async function startClean() {
const current = state.value;
if (current.isCleaning || !current.scanResult) return;
const selectedPaths = current.scanResult.items
.filter((item) => item.enabled)
.map((item) => item.path);
if (selectedPaths.length === 0) {
showAlert({
title: "未选择任何项",
message: "请至少勾选一个需要清理的项目。",
type: "info",
});
return;
}
if (selectedPaths.includes("C:\\$Recycle.Bin")) {
const confirmed = await requestConfirm({
title: "确认清空回收站",
message: "当前勾选项包含回收站。\n\n清空后回收站中的文件将被永久删除通常无法直接恢复。\n\n是否继续清理",
type: "info",
confirmText: "继续清理",
cancelText: "返回检查",
});
if (!confirmed) {
return;
}
}
resetCleanProgress();
current.isCleaning = true;
const unlisten = await subscribeFastCleanProgress(handleCleanProgress);
try {
current.cleanResult = await runFastCleanCommand(selectedPaths);
current.isDone = true;
current.scanResult = null;
} catch (err) {
showAlert({
title: "清理失败",
message: String(err),
type: "error",
});
} finally {
unlisten();
current.isCleaning = false;
}
}
function reset() {
state.value = {
isScanning: false,
isCleaning: false,
isDone: false,
progress: 0,
scanResult: null,
cleanResult: null,
};
resetCleanProgress();
}
return {
state,
selectedStats,
cleanProgress,
cleanProgressSizeStr,
startScan,
startClean,
reset,
};
}

View File

@@ -0,0 +1,82 @@
import { onMounted, onUnmounted, ref } from "vue";
import {
getMemoryStats as fetchMemoryStats,
runDeepMemoryClean,
runMemoryClean,
} from "../services/tauri/cleaner";
import type { AlertOptions, MemoryStats } from "../types/cleaner";
import { formatItemSize } from "../utils/format";
interface MemoryState {
stats: MemoryStats | null;
isCleaning: boolean;
cleaningType: "fast" | "deep" | null;
lastFreed: string;
isDone: boolean;
}
export function useMemoryClean(showAlert: (options: AlertOptions) => void) {
const state = ref<MemoryState>({
stats: null,
isCleaning: false,
cleaningType: null,
lastFreed: "",
isDone: false,
});
let memoryInterval: number | null = null;
async function getStats() {
try {
state.value.stats = await fetchMemoryStats();
} catch (err) {
console.error("Failed to fetch memory stats", err);
}
}
async function startClean(deep = false) {
if (state.value.isCleaning) return;
state.value.isCleaning = true;
state.value.cleaningType = deep ? "deep" : "fast";
try {
const freedBytes = deep ? await runDeepMemoryClean() : await runMemoryClean();
state.value.lastFreed = formatItemSize(freedBytes);
showAlert({
title: "优化完成",
message: `已为您释放 ${state.value.lastFreed} 内存空间`,
type: "success",
});
await getStats();
} catch (err) {
showAlert({
title: "清理失败",
message: String(err),
type: "error",
});
} finally {
state.value.isCleaning = false;
state.value.cleaningType = null;
}
}
onMounted(() => {
void getStats();
memoryInterval = window.setInterval(() => {
void getStats();
}, 3000);
});
onUnmounted(() => {
if (memoryInterval) {
window.clearInterval(memoryInterval);
}
});
return {
state,
getStats,
startClean,
};
}

View File

@@ -1,4 +1,7 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles/base.css";
import "./styles/layout.css";
import "./styles/common.css";
createApp(App).mount("#app");

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import { useAdvancedClean } from "../composables/useAdvancedClean";
import type { AlertOptions } from "../types/cleaner";
const props = defineProps<{
showAlert: (options: AlertOptions) => void;
}>();
const { expandedAdvanced, loading, runTask } = useAdvancedClean(props.showAlert);
</script>
<template>
<section class="page-container">
<div class="page-header">
<div class="header-info">
<h1>高级清理工具</h1>
<p>针对特定系统区域执行清理但都有注意事项和副作用在不理解的情况下慎点</p>
</div>
</div>
<div class="adv-card-list">
<div class="adv-card" :class="{ expanded: expandedAdvanced === 'dism' }">
<div class="adv-card-main" @click="expandedAdvanced = expandedAdvanced === 'dism' ? null : 'dism'">
<div class="adv-card-info">
<span class="adv-card-icon"></span>
<div class="adv-card-text">
<h3>系统组件清理 <small class="detail-hint">(点击查看详情)</small></h3>
<p>通过 DISM 命令移除不再需要的系统冗余组件</p>
</div>
</div>
<div class="adv-card-right">
<span class="expand-icon" :class="{ rotated: expandedAdvanced === 'dism' }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</span>
<button class="btn-action" :disabled="loading.dism" @click.stop="runTask('dism')">
{{ loading.dism ? "执行中..." : "执行" }}
</button>
</div>
</div>
<div v-show="expandedAdvanced === 'dism'" class="adv-card-detail">
<div class="detail-content">
<h4>详细信息</h4>
<p>Windows 在更新后会保留旧版本的组件此操作会调用系统底层的 DISM 工具StartComponentCleanup进行物理移除</p>
<h4 class="warning-title">注意事项</h4>
<ul>
<li>执行后将无法卸载已安装的 Windows 更新</li>
<li>过程可能较慢 1-5 分钟请勿中途关闭程序</li>
</ul>
</div>
</div>
</div>
<div class="adv-card" :class="{ expanded: expandedAdvanced === 'thumb' }">
<div class="adv-card-main" @click="expandedAdvanced = expandedAdvanced === 'thumb' ? null : 'thumb'">
<div class="adv-card-info">
<span class="adv-card-icon">🖼</span>
<div class="adv-card-text">
<h3>清理缩略图缓存 <small class="detail-hint">(点击查看详情)</small></h3>
<p>重置文件夹预览缩略图数据库以释放空间</p>
</div>
</div>
<div class="adv-card-right">
<span class="expand-icon" :class="{ rotated: expandedAdvanced === 'thumb' }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</span>
<button class="btn-action" :disabled="loading.thumb" @click.stop="runTask('thumb')">执行</button>
</div>
</div>
<div v-show="expandedAdvanced === 'thumb'" class="adv-card-detail">
<div class="detail-content">
<h4>详细信息</h4>
<p>系统会自动生成图片和视频的缩略图缓存thumbcache_*.db当缓存过大或出现显示错误时建议清理</p>
<h4 class="warning-title">注意事项</h4>
<ul>
<li>清理后再次打开图片文件夹时加载预览会稍慢</li>
<li>部分文件正被资源管理器使用时可能无法彻底删除</li>
</ul>
</div>
</div>
</div>
<div class="adv-card" :class="{ expanded: expandedAdvanced === 'hiber' }">
<div class="adv-card-main" @click="expandedAdvanced = expandedAdvanced === 'hiber' ? null : 'hiber'">
<div class="adv-card-info">
<span class="adv-card-icon">🌙</span>
<div class="adv-card-text">
<h3>关闭休眠文件 <small class="detail-hint">(点击查看详情)</small></h3>
<p>永久删除 hiberfil.sys 文件大小等同于内存</p>
</div>
</div>
<div class="adv-card-right">
<span class="expand-icon" :class="{ rotated: expandedAdvanced === 'hiber' }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</span>
<button class="btn-action" :disabled="loading.hiber" @click.stop="runTask('hiber')">执行</button>
</div>
</div>
<div v-show="expandedAdvanced === 'hiber'" class="adv-card-detail">
<div class="detail-content">
<h4>详细信息</h4>
<p>休眠文件hiberfil.sys占用大量 C 盘空间对于使用 SSD且不常用休眠功能的用户关闭它可以释放巨额空间</p>
<h4 class="warning-title">注意事项</h4>
<ul>
<li>关闭后将无法使用休眠功能及快速启动技术</li>
<li>只需执行一次</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.adv-card-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.adv-card {
background: #fff;
border-radius: 20px;
box-shadow: var(--card-shadow);
border: 1px solid rgba(0, 0, 0, 0.02);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.adv-card:hover {
transform: translateY(-2px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.06);
}
.adv-card-main {
padding: 24px 32px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.adv-card-main:hover {
background: #fafafa;
}
.adv-card-info {
display: flex;
align-items: center;
gap: 24px;
}
.adv-card-icon {
font-size: 32px;
}
.adv-card-text h3 {
font-size: 18px;
margin-bottom: 4px;
font-weight: 700;
color: var(--text-main);
display: flex;
align-items: center;
gap: 8px;
}
.adv-card-text p {
color: var(--text-sec);
font-size: 14px;
}
.detail-hint {
font-size: 12px;
color: var(--text-sec);
font-weight: 400;
opacity: 0.7;
}
.adv-card-right {
display: flex;
align-items: center;
gap: 16px;
}
.expand-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #c1c1c1;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
background: #f8f9fa;
}
.expand-icon svg {
width: 18px;
height: 18px;
}
.expand-icon.rotated {
transform: rotate(180deg);
background: #ebf4ff;
color: var(--primary-color);
}
.btn-action {
background-color: #f2f2f7;
color: var(--primary-color);
border: none;
padding: 10px 24px;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 90px;
}
.btn-action:hover {
background-color: var(--primary-color);
color: #fff;
transform: scale(1.05);
}
.btn-action:disabled {
background-color: #e5e5e7;
color: #a1a1a1;
cursor: not-allowed;
transform: none;
}
.adv-card-detail {
padding: 0 32px 32px 88px;
border-top: 1px solid #f5f5f7;
background: #fcfcfd;
}
.detail-content {
padding-top: 24px;
}
.detail-content h4 {
font-size: 14px;
margin-bottom: 10px;
color: var(--text-main);
font-weight: 700;
}
.detail-content p {
font-size: 14px;
color: var(--text-sec);
line-height: 1.6;
margin-bottom: 16px;
}
.warning-title {
color: #ff9500 !important;
margin-top: 20px;
}
.detail-content ul {
padding-left: 18px;
color: var(--text-sec);
font-size: 13px;
}
.detail-content li {
margin-bottom: 8px;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { useBrowserClean } from "../composables/useBrowserClean";
import type { AlertOptions, ConfirmOptions } from "../types/cleaner";
import { splitSize } from "../utils/format";
const props = defineProps<{
browser: "chrome" | "edge";
showAlert: (options: AlertOptions) => void;
requestConfirm: (options: ConfirmOptions) => Promise<boolean>;
}>();
const { state, selectedStats, cleanProgress, cleanProgressSizeStr, startScan, startClean, toggleAllProfiles, invertProfiles, reset } =
useBrowserClean(props.browser, props.showAlert, props.requestConfirm);
const browserName = props.browser === "chrome" ? "谷歌浏览器" : "微软浏览器";
</script>
<template>
<section class="page-container">
<div class="page-header">
<div class="header-info">
<h1>清理{{ browserName }}</h1>
<p>安全清理浏览器缓存临时文件等不会删除账号和插件数据注意清理前需要关闭浏览器</p>
</div>
</div>
<div class="main-action">
<div v-if="!state.scanResult && !state.isDone" class="scan-circle-container">
<div class="scan-circle" :class="{ scanning: state.isScanning }">
<div class="scan-inner" @click="!state.isScanning && startScan()">
<span v-if="!state.isScanning" class="scan-btn-text">开始扫描</span>
<span v-else class="spinner"></span>
</div>
</div>
</div>
<div v-else-if="state.scanResult && !state.isDone" class="result-card">
<div class="result-header">
<span class="result-icon">{{ state.isCleaning ? "🧹" : "🌍" }}</span>
<h2>{{ state.isCleaning ? "正在清理" : "扫描完成" }}</h2>
</div>
<div class="result-stats">
<div class="stat-item">
<span class="stat-value">
{{ splitSize(state.isCleaning ? cleanProgressSizeStr : selectedStats.sizeStr).value }}
<span class="unit">{{ splitSize(state.isCleaning ? cleanProgressSizeStr : selectedStats.sizeStr).unit }}</span>
</span>
<span class="stat-label">{{ state.isCleaning ? "已处理约" : "预计释放" }}</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value">{{ state.isCleaning ? `${cleanProgress.completedItems}/${cleanProgress.totalItems}` : selectedStats.count }}</span>
<span class="stat-label">{{ state.isCleaning ? "已完成资料" : "用户资料数量" }}</span>
</div>
</div>
<p v-if="state.isCleaning" class="cleaning-note">
正在清理{{ cleanProgress.currentItem || "准备开始..." }}建议保持浏览器关闭以减少文件占用
</p>
<button class="btn-primary main-btn" :disabled="state.isCleaning || !selectedStats.hasSelection" @click="startClean">
{{ state.isCleaning ? "正在清理..." : "立即清理" }}
</button>
</div>
<div v-else-if="state.isDone && state.cleanResult" class="result-card done-card">
<div class="result-header">
<span class="result-icon success">🎉</span>
<h2>清理完成</h2>
</div>
<div class="result-stats">
<div class="stat-item">
<span class="stat-value">
{{ splitSize(state.cleanResult.total_freed).value }}
<span class="unit">{{ splitSize(state.cleanResult.total_freed).unit }}</span>
</span>
<span class="stat-label">释放空间</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value">{{ state.cleanResult.success_count }}</span>
<span class="stat-label">成功清理</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value highlight-gray">{{ state.cleanResult.fail_count }}</span>
<span class="stat-label">跳过/失败</span>
</div>
</div>
<button class="btn-secondary" @click="reset">返回</button>
</div>
</div>
<div v-if="(state.isScanning || state.scanResult) && !state.isDone && !state.isCleaning" class="detail-list">
<div class="list-header">
<h3>用户资料列表</h3>
<div class="list-actions">
<button class="btn-text" @click="toggleAllProfiles(true)">全选</button>
<button class="btn-text" @click="toggleAllProfiles(false)">取消</button>
<button class="btn-text" @click="invertProfiles()">反选</button>
</div>
</div>
<div
v-for="profile in state.scanResult?.profiles || []"
:key="profile.path_name"
class="detail-item"
:class="{ disabled: !profile.enabled }"
@click="profile.enabled = !profile.enabled"
>
<div class="item-info">
<label class="checkbox-container" @click.stop>
<input v-model="profile.enabled" type="checkbox">
<span class="checkmark"></span>
</label>
<span>{{ profile.name }}</span>
</div>
<span class="item-size">{{ profile.cache_size_str }}</span>
</div>
<div v-if="state.isScanning" class="scanning-placeholder">正在定位并分析浏览器用户资料...</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import { useDiskAnalysis } from "../composables/useDiskAnalysis";
import type { AlertOptions, FileNode } from "../types/cleaner";
const props = defineProps<{
showAlert: (options: AlertOptions) => void;
}>();
const emit = defineEmits<{
"open-context-menu": [event: MouseEvent, node: FileNode];
}>();
const { isFullScanning, fullScanProgress, treeData, startFullDiskScan, toggleNode } =
useDiskAnalysis(props.showAlert);
</script>
<template>
<section class="page-container full-width">
<div class="page-header">
<div class="header-info">
<h1>查找大目录</h1>
<p>查看 C 盘目录大小适合技术人员细节分析空间占用情况</p>
</div>
<div class="header-actions">
<button class="btn-primary btn-sm" :disabled="isFullScanning" @click="startFullDiskScan">
{{ isFullScanning ? "正在扫描..." : "开始扫描" }}
</button>
</div>
</div>
<div v-if="treeData.length > 0 || isFullScanning" class="tree-table-container shadow-card">
<div v-if="isFullScanning" class="scanning-overlay">
<div class="spinner"></div>
<div class="scanning-status">
<p class="scanning-main-text">正在扫描 C 盘文件...</p>
<div class="scanning-stats-row">
<span class="stat-badge">已扫描{{ fullScanProgress.fileCount.toLocaleString() }} 个文件</span>
</div>
<p v-if="fullScanProgress.currentPath" class="scanning-current-path">
当前{{ fullScanProgress.currentPath }}
</p>
</div>
</div>
<div v-else class="tree-content-wrapper">
<div class="tree-header">
<span class="col-name">文件/文件夹名称</span>
<span class="col-size">大小</span>
<span class="col-graph">相对于父目录占比</span>
</div>
<div class="tree-body">
<div
v-for="(node, index) in treeData"
:key="node.path"
class="tree-row"
:class="{ 'is-file': !node.is_dir }"
:style="{ paddingLeft: `${node.level * 20 + 16}px` }"
@contextmenu="emit('open-context-menu', $event, node)"
>
<div class="col-name" @click="toggleNode(index)">
<span v-if="node.is_dir" class="node-toggle">
{{ node.isLoading ? "" : node.isOpen ? "" : "" }}
</span>
<span v-else class="node-icon svg-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
</span>
<span class="node-text">{{ node.name }}</span>
</div>
<div class="col-size">{{ node.size_str }}</div>
<div class="col-graph">
<div class="mini-bar-bg">
<div class="mini-bar-fill" :style="{ width: `${node.percent}%` }"></div>
</div>
<span class="percent-text">{{ Math.round(node.percent) }}%</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.tree-table-container {
background: #fff;
border-radius: 24px;
overflow: hidden;
margin-top: 8px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow: var(--card-shadow);
border: 1px solid rgba(0, 0, 0, 0.02);
}
.tree-content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tree-header {
display: flex;
background: #f9fafb;
padding: 16px 24px;
font-size: 12px;
font-weight: 700;
color: var(--text-sec);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.tree-body {
flex: 1;
overflow-y: auto;
}
.tree-row {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid #f5f5f7;
font-size: 14px;
transition: background 0.15s ease;
}
.tree-row:hover {
background: #f9f9fb;
}
.tree-row.is-file {
color: #424245;
}
.col-name {
flex: 2;
display: flex;
align-items: center;
font-weight: 500;
min-width: 0;
}
.node-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.col-size {
width: 100px;
text-align: right;
font-weight: 600;
color: var(--text-main);
flex-shrink: 0;
}
.col-graph {
width: 180px;
display: flex;
align-items: center;
gap: 12px;
padding-left: 32px;
flex-shrink: 0;
}
.mini-bar-bg {
flex: 1;
height: 6px;
background: #f0f0f2;
border-radius: 3px;
overflow: hidden;
}
.mini-bar-fill {
height: 100%;
background: linear-gradient(90deg, #007aff, #5856d6);
border-radius: 3px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.percent-text {
font-size: 11px;
color: var(--text-sec);
width: 32px;
font-weight: 600;
text-align: right;
}
.node-toggle {
width: 24px;
cursor: pointer;
color: #c1c1c1;
display: inline-block;
text-align: center;
font-size: 10px;
transition: color 0.2s;
}
.node-toggle:hover {
color: var(--primary-color);
}
.node-icon {
width: 24px;
font-size: 14px;
opacity: 0.7;
}
</style>

115
src/pages/FastCleanPage.vue Normal file
View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { useFastClean } from "../composables/useFastClean";
import type { AlertOptions, ConfirmOptions } from "../types/cleaner";
import { splitSize, formatItemSize } from "../utils/format";
const props = defineProps<{
showAlert: (options: AlertOptions) => void;
requestConfirm: (options: ConfirmOptions) => Promise<boolean>;
}>();
const { state, selectedStats, cleanProgress, cleanProgressSizeStr, startScan, startClean, reset } = useFastClean(
props.showAlert,
props.requestConfirm,
);
</script>
<template>
<section class="page-container">
<div class="page-header">
<div class="header-info">
<h1>清理系统盘</h1>
<p>快速清理 C 盘缓存不影响系统运行</p>
</div>
</div>
<div class="main-action">
<div v-if="!state.scanResult && !state.isDone" class="scan-circle-container">
<div class="scan-circle" :class="{ scanning: state.isScanning }">
<div class="scan-inner" @click="!state.isScanning && startScan()">
<span v-if="!state.isScanning" class="scan-btn-text">开始扫描</span>
<span v-else class="scan-percent">{{ state.progress }}%</span>
</div>
</div>
</div>
<div v-else-if="state.scanResult && !state.isDone" class="result-card">
<div class="result-header">
<span class="result-icon">{{ state.isCleaning ? "🧹" : "📋" }}</span>
<h2>{{ state.isCleaning ? "正在清理" : "扫描完成" }}</h2>
</div>
<div class="result-stats">
<div class="stat-item">
<span class="stat-value">
{{ splitSize(state.isCleaning ? cleanProgressSizeStr : selectedStats.sizeStr).value }}
<span class="unit">{{ splitSize(state.isCleaning ? cleanProgressSizeStr : selectedStats.sizeStr).unit }}</span>
</span>
<span class="stat-label">{{ state.isCleaning ? "已处理约" : "预计释放" }}</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value">{{ state.isCleaning ? `${cleanProgress.completedItems}/${cleanProgress.totalItems}` : selectedStats.count }}</span>
<span class="stat-label">{{ state.isCleaning ? "已完成项目" : "文件数量" }}</span>
</div>
</div>
<p v-if="state.isCleaning" class="cleaning-note">
正在清理{{ cleanProgress.currentItem || "准备开始..." }}请稍候不要关闭程序
</p>
<button class="btn-primary main-btn" :disabled="state.isCleaning || !selectedStats.hasSelection" @click="startClean">
{{ state.isCleaning ? "正在清理..." : "立即清理" }}
</button>
</div>
<div v-else-if="state.isDone && state.cleanResult" class="result-card done-card">
<div class="result-header">
<span class="result-icon success">🎉</span>
<h2>清理完成</h2>
</div>
<div class="result-stats">
<div class="stat-item">
<span class="stat-value">
{{ splitSize(state.cleanResult.total_freed).value }}
<span class="unit">{{ splitSize(state.cleanResult.total_freed).unit }}</span>
</span>
<span class="stat-label">释放空间</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value">{{ state.cleanResult.success_count }}</span>
<span class="stat-label">成功清理</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value highlight-gray">{{ state.cleanResult.fail_count }}</span>
<span class="stat-label">跳过/失败</span>
</div>
</div>
<button class="btn-secondary" @click="reset">返回</button>
</div>
</div>
<div v-if="(state.isScanning || state.scanResult) && !state.isDone && !state.isCleaning" class="detail-list">
<h3>清理项详情</h3>
<div
v-for="item in state.scanResult?.items || []"
:key="item.path"
class="detail-item"
:class="{ disabled: !item.enabled }"
@click="item.enabled = !item.enabled"
>
<div class="item-info">
<label class="checkbox-container" @click.stop>
<input v-model="item.enabled" type="checkbox">
<span class="checkmark"></span>
</label>
<span>{{ item.name }}</span>
</div>
<span class="item-size">{{ formatItemSize(item.size) }}</span>
</div>
<div v-if="state.isScanning" class="scanning-placeholder">正在深度扫描文件系统...</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { useMemoryClean } from "../composables/useMemoryClean";
import type { AlertOptions } from "../types/cleaner";
import { formatItemSize } from "../utils/format";
const props = defineProps<{
showAlert: (options: AlertOptions) => void;
}>();
const { state, startClean } = useMemoryClean(props.showAlert);
</script>
<template>
<section class="page-container memory-page-spread">
<div class="page-header">
<div class="header-info">
<h1>清理内存</h1>
<p>释放内存占用不影响程序运行但释放内存后重新打开之前的软件会感到略微卡顿</p>
</div>
</div>
<div class="memory-layout-v2">
<div class="memory-main-card shadow-card">
<div class="gauge-section">
<div class="memory-gauge" :style="{ '--percent': state.stats?.percent || 0 }">
<svg viewBox="0 0 100 100">
<circle class="gauge-bg" cx="50" cy="50" r="45"></circle>
<circle class="gauge-fill" cx="50" cy="50" r="45" :style="{ strokeDashoffset: 283 - (283 * (state.stats?.percent || 0)) / 100 }"></circle>
</svg>
<div class="gauge-content">
<span class="gauge-value">{{ Math.round(state.stats?.percent || 0) }}<small>%</small></span>
<span class="gauge-label">内存占用</span>
</div>
</div>
</div>
<div class="stats-section">
<div class="stat-box-v2">
<span class="label">已用内存</span>
<span class="value">{{ formatItemSize(state.stats?.used || 0) }}</span>
</div>
<div class="stat-divider-h"></div>
<div class="stat-box-v2">
<span class="label">可用内存</span>
<span class="value">{{ formatItemSize(state.stats?.free || 0) }}</span>
</div>
<div class="stat-divider-h"></div>
<div class="stat-box-v2">
<span class="label">内存总量</span>
<span class="value">{{ formatItemSize(state.stats?.total || 0) }}</span>
</div>
</div>
</div>
<div class="memory-actions-v2">
<div class="action-card shadow-card" :class="{ cleaning: state.isCleaning }">
<div class="action-info">
<h3>普通加速</h3>
<p>建议在需要开启更多软件但内存占用居高不下时使用</p>
</div>
<button class="btn-primary" :disabled="state.isCleaning" @click="startClean(false)">
{{ state.cleaningType === "fast" ? "清理中..." : "立即加速" }}
</button>
</div>
<div class="action-card shadow-card secondary" :class="{ cleaning: state.isCleaning }">
<div class="action-info">
<h3>深度加速</h3>
<p>可以在长时间使用电脑后感觉电脑有点卡顿时执行</p>
</div>
<button class="btn-secondary" :disabled="state.isCleaning" @click="startClean(true)">
{{ state.cleaningType === "deep" ? "清理中..." : "深度加速" }}
</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.memory-layout-v2 {
display: flex;
flex-direction: column;
gap: 32px;
margin-top: 24px;
}
.memory-main-card {
background: white;
border-radius: 32px;
padding: 48px;
display: flex;
align-items: center;
gap: 60px;
box-shadow: var(--card-shadow);
}
.gauge-section {
flex: 0 0 auto;
}
.memory-gauge {
position: relative;
width: 240px;
height: 240px;
}
.memory-gauge svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.gauge-bg {
fill: none;
stroke: #f2f2f7;
stroke-width: 8;
}
.gauge-fill {
fill: none;
stroke: var(--primary-color);
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 283;
transition: stroke-dashoffset 1s cubic-bezier(0.4, 0, 0.2, 1), stroke 0.3s;
}
.gauge-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
display: flex;
flex-direction: column;
}
.gauge-value {
font-size: 64px;
font-weight: 800;
color: var(--text-main);
line-height: 1;
letter-spacing: -2px;
}
.gauge-value small {
font-size: 24px;
margin-left: 2px;
letter-spacing: 0;
}
.gauge-label {
font-size: 14px;
color: var(--text-sec);
font-weight: 600;
margin-top: 4px;
}
.stats-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.stat-box-v2 {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.stat-box-v2 .label {
font-size: 15px;
color: var(--text-sec);
font-weight: 500;
}
.stat-box-v2 .value {
font-size: 20px;
font-weight: 700;
color: var(--text-main);
}
.stat-divider-h {
height: 1px;
background: #f2f2f7;
width: 100%;
}
.memory-actions-v2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.action-card {
background: white;
padding: 32px;
border-radius: 28px;
display: flex;
flex-direction: column;
gap: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-card:hover {
transform: translateY(-4px);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.06);
}
.action-card.secondary {
background-color: #fbfbfd;
border: 1px dashed var(--border-color);
box-shadow: none;
}
.action-info {
flex: 1;
}
.action-info h3 {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-main);
}
.action-info p {
font-size: 13px;
color: var(--text-sec);
line-height: 1.5;
}
.action-card :deep(.btn-primary),
.action-card :deep(.btn-secondary) {
width: 100%;
padding: 14px;
}
.action-card.cleaning {
filter: grayscale(1);
opacity: 0.7;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,98 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import type {
BrowserScanResult,
CleanResult,
FastScanResult,
FileNode,
MemoryStats,
ProjectCleanProgressPayload,
ScanProgressPayload,
} from "../../types/cleaner";
export function startFastScan() {
return invoke<FastScanResult>("start_fast_scan");
}
export function startFastClean(selectedPaths: string[]) {
return invoke<CleanResult>("start_fast_clean", { selectedPaths });
}
export function cleanSystemComponents() {
return invoke<string>("clean_system_components");
}
export function cleanThumbnails() {
return invoke<string>("clean_thumbnails");
}
export function disableHibernation() {
return invoke<string>("disable_hibernation");
}
export function startBrowserScan(browser: "chrome" | "edge") {
return invoke<BrowserScanResult>("start_browser_scan", { browser });
}
export function startBrowserClean(browser: "chrome" | "edge", profiles: string[]) {
return invoke<CleanResult>("start_browser_clean", { browser, profiles });
}
export function startFullDiskScan() {
return invoke("start_full_disk_scan");
}
export function getTreeChildren(path: string) {
return invoke<FileNode[]>("get_tree_children", { path });
}
export function subscribeScanProgress(
handler: (payload: ScanProgressPayload) => void,
) {
return listen<ScanProgressPayload>("scan-progress", (event) => {
handler(event.payload);
});
}
export function subscribeFastCleanProgress(
handler: (payload: ProjectCleanProgressPayload) => void,
) {
return listen<ProjectCleanProgressPayload>("fast-clean-progress", (event) => {
handler(event.payload);
});
}
export function subscribeBrowserCleanProgress(
handler: (payload: ProjectCleanProgressPayload) => void,
) {
return listen<ProjectCleanProgressPayload>("browser-clean-progress", (event) => {
handler(event.payload);
});
}
export function openInExplorer(path: string) {
return invoke("open_in_explorer", { path });
}
export function openSearch(query: string, provider: "google" | "perplexity") {
const encoded = encodeURIComponent(query);
const url =
provider === "google"
? `https://www.google.com/search?q=${encoded}`
: `https://www.perplexity.ai/?q=${encoded}`;
return openUrl(url);
}
export function getMemoryStats() {
return invoke<MemoryStats>("get_memory_stats");
}
export function runMemoryClean() {
return invoke<number>("run_memory_clean");
}
export function runDeepMemoryClean() {
return invoke<number>("run_deep_memory_clean");
}

87
src/styles/base.css Normal file
View File

@@ -0,0 +1,87 @@
:root {
--primary-color: #007aff;
--primary-hover: #0063cc;
--bg-light: #fbfbfd;
--sidebar-bg: #ffffff;
--text-main: #1d1d1f;
--text-sec: #86868b;
--border-color: #e5e5e7;
--card-shadow: 0 12px 30px rgba(0, 0, 0, 0.04);
--btn-shadow: 0 4px 12px rgba(0, 122, 255, 0.25);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
color: var(--text-main);
background-color: var(--bg-light);
height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
#app {
height: 100%;
}
.app-container {
display: flex;
height: 100%;
}
.svg-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.svg-icon svg {
width: 1.25em;
height: 1.25em;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.svg-icon.big svg {
width: 2.5em;
height: 2.5em;
}
.icon.svg-icon {
margin-right: 12px;
color: inherit;
}
.icon.svg-icon svg {
width: 18px;
height: 18px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.9) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}

525
src/styles/common.css Normal file
View File

@@ -0,0 +1,525 @@
.btn-primary {
background-color: var(--primary-color);
color: white;
border: none;
padding: 12px 40px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--btn-shadow);
flex-shrink: 0;
}
.btn-sm {
padding: 8px 24px;
font-size: 14px;
border-radius: 10px;
}
.btn-primary:hover {
background-color: var(--primary-hover);
transform: translateY(-1.5px);
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.35);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
background-color: #d1d1d6;
box-shadow: none;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background-color: white;
color: var(--text-main);
border: 1px solid var(--border-color);
padding: 10px 28px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background-color: #f5f5f7;
}
.main-action {
margin: 24px 0;
display: flex;
justify-content: center;
}
.result-card {
background: white;
border-radius: 24px;
padding: 32px 40px;
width: 100%;
box-shadow: var(--card-shadow);
text-align: center;
border: 1px solid rgba(0, 0, 0, 0.02);
}
.result-header {
margin-bottom: 24px;
}
.result-icon {
font-size: 28px;
display: block;
margin-bottom: 8px;
}
.result-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-main);
}
.result-stats {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 32px;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
display: flex;
align-items: baseline;
justify-content: center;
font-size: 38px;
font-weight: 800;
color: var(--primary-color);
letter-spacing: -1.2px;
line-height: 1;
margin-bottom: 6px;
white-space: nowrap;
}
.stat-value .unit {
font-size: 14px;
font-weight: 700;
margin-left: 3px;
letter-spacing: 0;
opacity: 0.9;
}
.stat-label {
font-size: 13px;
color: var(--text-sec);
font-weight: 500;
}
.stat-divider {
width: 1px;
height: 40px;
background-color: #f2f2f7;
margin: 0 16px;
flex-shrink: 0;
}
.main-btn {
width: 180px;
}
.cleaning-note {
margin: -8px 0 24px;
font-size: 13px;
line-height: 1.6;
color: var(--text-sec);
}
.scan-circle-container {
width: 200px;
height: 200px;
margin: 0 auto;
}
.scan-circle {
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid #f2f2f7;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.scan-circle.scanning {
border-color: transparent;
}
.scan-circle.scanning::before {
content: "";
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border-radius: 50%;
border: 3px solid var(--primary-color);
border-top-color: transparent;
animation: spin 1s linear infinite;
}
.scan-inner {
width: 168px;
height: 168px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease;
}
.scan-inner:hover {
transform: scale(1.03);
}
.scan-btn-text {
font-size: 18px;
font-weight: 700;
color: var(--primary-color);
}
.scan-percent {
font-size: 36px;
font-weight: 800;
color: var(--primary-color);
letter-spacing: -1px;
}
.stat-value.highlight-gray {
color: #8e8e93;
}
.done-card {
border: 2px solid #e8f5e9;
}
.result-icon.success {
color: #34c759;
font-size: 48px;
margin-bottom: 12px;
display: block;
}
.scanning-loader,
.scanning-overlay {
padding: 100px 40px;
text-align: center;
color: var(--text-sec);
}
.scanning-status {
margin-top: 16px;
}
.scanning-main-text {
font-size: 16px;
font-weight: 600;
color: var(--text-main);
margin-bottom: 12px;
}
.scanning-stats-row {
margin-bottom: 16px;
}
.stat-badge {
background: #ebf4ff;
color: var(--primary-color);
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
}
.scanning-current-path {
font-size: 12px;
color: var(--text-sec);
max-width: 500px;
margin: 0 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
opacity: 0.8;
}
.spinner {
width: 44px;
height: 44px;
border: 3px solid #f2f2f7;
border-top: 3px solid var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 24px;
}
.detail-list {
background: white;
border-radius: 20px;
padding: 32px;
box-shadow: var(--card-shadow);
margin-top: 40px;
border: 1px solid rgba(0, 0, 0, 0.02);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.list-header h3 {
font-size: 18px;
margin-bottom: 0;
font-weight: 700;
color: var(--text-main);
}
.list-actions {
display: flex;
gap: 8px;
}
.btn-text {
background: none;
border: none;
color: var(--primary-color);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 6px 10px;
border-radius: 8px;
transition: all 0.2s;
}
.btn-text:hover {
background-color: #f0f7ff;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f5f5f7;
font-size: 14px;
color: #424245;
transition: all 0.2s ease;
cursor: pointer;
}
.detail-item:hover {
background-color: #fafafb;
padding-left: 8px;
padding-right: 8px;
border-radius: 8px;
}
.detail-item.disabled {
opacity: 0.5;
}
.detail-item:last-child {
border-bottom: none;
}
.item-info {
display: flex;
align-items: center;
gap: 12px;
}
.checkbox-container {
display: block;
position: relative;
width: 20px;
height: 20px;
cursor: pointer;
user-select: none;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
background-color: #f2f2f7;
border-radius: 6px;
transition: all 0.2s;
border: 1px solid #e5e5e7;
}
.checkbox-container:hover input ~ .checkmark {
background-color: #e5e5e7;
}
.checkbox-container input:checked ~ .checkmark {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.checkmark:after {
content: "";
position: absolute;
display: none;
left: 6px;
top: 1px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-container input:checked ~ .checkmark:after {
display: block;
}
.item-size {
font-weight: 600;
color: var(--primary-color);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal-card {
background: white;
width: 400px;
border-radius: 24px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
text-align: center;
animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-header {
margin-bottom: 20px;
}
.modal-icon {
font-size: 40px;
display: block;
margin-bottom: 12px;
}
.modal-header h3 {
font-size: 20px;
font-weight: 700;
color: var(--text-main);
}
.modal-card.success .modal-header h3 {
color: #34c759;
}
.modal-card.error .modal-header h3 {
color: #ff3b30;
}
.modal-body {
margin-bottom: 32px;
}
.modal-body p {
color: var(--text-sec);
font-size: 15px;
line-height: 1.6;
white-space: pre-line;
}
.modal-footer {
display: flex;
justify-content: center;
gap: 12px;
}
.modal-footer.is-confirm {
justify-content: center;
}
.modal-btn {
min-width: 120px;
}
.context-menu {
position: fixed;
background: white;
min-width: 180px;
border-radius: 12px;
padding: 6px;
z-index: 2000;
border: 1px solid rgba(0, 0, 0, 0.08);
animation: fadeIn 0.1s ease;
}
.menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--text-main);
cursor: pointer;
border-radius: 8px;
transition: background 0.15s;
}
.menu-item:hover {
background-color: #f2f2f7;
color: var(--primary-color);
}
.menu-icon {
font-size: 16px;
}
.menu-divider {
height: 1px;
background: #f5f5f7;
margin: 4px 0;
}

183
src/styles/layout.css Normal file
View File

@@ -0,0 +1,183 @@
.sidebar {
width: 240px;
background-color: #f8fafd;
border-right: 1px solid #e9eff6;
display: flex;
flex-direction: column;
padding: 40px 0 20px;
z-index: 10;
}
.sidebar-header {
padding: 0 28px 36px;
}
.brand {
font-size: 20px;
font-weight: 700;
color: var(--text-main);
letter-spacing: -0.3px;
}
.sidebar-nav {
flex: 1;
}
.nav-item,
.nav-item-header {
padding: 12px 20px;
margin: 4px 12px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #4a5568;
font-size: 14px;
font-weight: 500;
border-radius: 12px;
}
.nav-item:hover,
.nav-item-header:hover {
background-color: #edf2f7;
color: #2d3748;
}
.nav-item.active {
background-color: #ebf4ff;
color: #04448a;
font-weight: 600;
}
.icon {
margin-right: 12px;
font-size: 18px;
width: 24px;
text-align: center;
}
.arrow {
margin-left: auto;
transition: transform 0.3s;
font-size: 10px;
color: #a0aec0;
}
.arrow.open {
transform: rotate(180deg);
color: #4a5568;
}
.nav-sub-items {
margin-bottom: 8px;
}
.nav-sub-item {
padding: 10px 20px 10px 52px;
margin: 2px 12px;
cursor: pointer;
font-size: 13px;
color: #718096;
transition: all 0.2s;
border-radius: 10px;
}
.nav-sub-item:hover {
background-color: #edf2f7;
color: #2d3748;
}
.nav-sub-item.active {
color: #007aff;
font-weight: 600;
background-color: #ebf4ff;
}
.sidebar-footer {
padding: 0 20px;
text-align: center;
}
.version {
font-size: 12px;
color: #cbd5e0;
font-weight: 500;
letter-spacing: 0.2px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.content {
flex: 1;
padding: 40px 60px;
overflow-y: auto;
height: 100%;
scrollbar-gutter: stable;
}
.content:has(.page-container.full-width) {
overflow-y: hidden;
padding-bottom: 24px;
}
.page-container {
max-width: 800px;
margin: 0 auto;
padding-bottom: 0;
transition: max-width 0.4s ease;
}
.page-container.full-width {
max-width: 1400px;
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.header-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-header h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 0;
color: var(--text-main);
line-height: 1.2;
}
.page-header p {
color: var(--text-sec);
font-size: 13px;
margin-bottom: 0;
line-height: 1.2;
}
.header-actions {
display: flex;
align-items: center;
}
.placeholder-page {
padding-top: 120px;
text-align: center;
color: var(--text-sec);
}
.empty-icon {
font-size: 64px;
display: block;
margin-bottom: 24px;
opacity: 0.5;
}

86
src/types/cleaner.ts Normal file
View File

@@ -0,0 +1,86 @@
export type Tab =
| "clean-c-fast"
| "clean-c-advanced"
| "clean-c-deep"
| "clean-browser-chrome"
| "clean-browser-edge"
| "clean-memory";
export interface ScanItem {
name: string;
path: string;
size: number;
count: number;
enabled: boolean;
}
export interface FastScanResult {
items: ScanItem[];
total_size: string;
total_count: number;
}
export interface CleanResult {
total_freed: string;
success_count: number;
fail_count: number;
}
export interface BrowserProfile {
name: string;
path_name: string;
cache_size: number;
cache_size_str: string;
enabled: boolean;
}
export interface BrowserScanResult {
profiles: BrowserProfile[];
total_size: string;
}
export interface FileNode {
name: string;
path: string;
is_dir: boolean;
size: number;
size_str: string;
percent: number;
has_children: boolean;
level: number;
isOpen: boolean;
isLoading: boolean;
}
export interface MemoryStats {
total: number;
used: number;
free: number;
percent: number;
}
export interface ScanProgressPayload {
file_count: number;
current_path: string;
}
export interface ProjectCleanProgressPayload {
completed_items: number;
total_items: number;
current_item: string;
approx_completed_bytes: number;
}
export type ModalType = "info" | "success" | "error";
export type ModalMode = "alert" | "confirm";
export interface AlertOptions {
title: string;
message: string;
type?: ModalType;
}
export interface ConfirmOptions extends AlertOptions {
confirmText?: string;
cancelText?: string;
}

20
src/utils/format.ts Normal file
View File

@@ -0,0 +1,20 @@
export function formatItemSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function splitSize(sizeStr: string | number) {
const str = String(sizeStr);
const parts = str.split(" ");
if (parts.length === 2) {
return { value: parts[0], unit: parts[1] };
}
return { value: str, unit: "" };
}