Compare commits
10 Commits
v0.1.0.031
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94fb93eed1 | ||
|
|
dab7209a69 | ||
|
|
4e40fa9f80 | ||
|
|
54b8701644 | ||
|
|
8764af1a56 | ||
|
|
ab1da1ff0e | ||
|
|
fd2566ca26 | ||
|
|
40215bcbcb | ||
|
|
11a8955aca | ||
|
|
9e06791019 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "win-cleaner",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
1
src-tauri/.gitignore
vendored
1
src-tauri/.gitignore
vendored
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -4594,7 +4594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "win-cleaner"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"jwalk",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "win-cleaner"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "A Windows Cleaner"
|
||||
authors = ["Julian"]
|
||||
edition = "2021"
|
||||
|
||||
61
src-tauri/src/backend/advanced_clean.rs
Normal file
61
src-tauri/src/backend/advanced_clean.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
128
src-tauri/src/backend/browser_clean.rs
Normal file
128
src-tauri/src/backend/browser_clean.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
103
src-tauri/src/backend/disk_analysis.rs
Normal file
103
src-tauri/src/backend/disk_analysis.rs
Normal 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(())
|
||||
}
|
||||
184
src-tauri/src/backend/fast_clean.rs
Normal file
184
src-tauri/src/backend/fast_clean.rs
Normal 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)
|
||||
}
|
||||
62
src-tauri/src/backend/memory_clean.rs
Normal file
62
src-tauri/src/backend/memory_clean.rs
Normal 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))
|
||||
}
|
||||
8
src-tauri/src/backend/mod.rs
Normal file
8
src-tauri/src/backend/mod.rs
Normal 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;
|
||||
96
src-tauri/src/backend/models.rs
Normal file
96
src-tauri/src/backend/models.rs
Normal 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,
|
||||
}
|
||||
6
src-tauri/src/backend/state.rs
Normal file
6
src-tauri/src/backend/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct DiskState {
|
||||
pub dir_sizes: Mutex<HashMap<String, u64>>,
|
||||
}
|
||||
26
src-tauri/src/backend/utils.rs
Normal file
26
src-tauri/src/backend/utils.rs
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
1967
src/App.vue
1967
src/App.vue
File diff suppressed because it is too large
Load Diff
44
src/components/common/AppModal.vue
Normal file
44
src/components/common/AppModal.vue
Normal 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>
|
||||
76
src/components/layout/AppSidebar.vue
Normal file
76
src/components/layout/AppSidebar.vue
Normal 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>
|
||||
48
src/composables/useAdvancedClean.ts
Normal file
48
src/composables/useAdvancedClean.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
197
src/composables/useBrowserClean.ts
Normal file
197
src/composables/useBrowserClean.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
89
src/composables/useDiskAnalysis.ts
Normal file
89
src/composables/useDiskAnalysis.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
185
src/composables/useFastClean.ts
Normal file
185
src/composables/useFastClean.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
82
src/composables/useMemoryClean.ts
Normal file
82
src/composables/useMemoryClean.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
273
src/pages/AdvancedCleanPage.vue
Normal file
273
src/pages/AdvancedCleanPage.vue
Normal 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>
|
||||
120
src/pages/BrowserCleanPage.vue
Normal file
120
src/pages/BrowserCleanPage.vue
Normal 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>
|
||||
213
src/pages/DiskAnalysisPage.vue
Normal file
213
src/pages/DiskAnalysisPage.vue
Normal 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
115
src/pages/FastCleanPage.vue
Normal 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>
|
||||
247
src/pages/MemoryCleanPage.vue
Normal file
247
src/pages/MemoryCleanPage.vue
Normal 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>
|
||||
98
src/services/tauri/cleaner.ts
Normal file
98
src/services/tauri/cleaner.ts
Normal 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
87
src/styles/base.css
Normal 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
525
src/styles/common.css
Normal 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
183
src/styles/layout.css
Normal 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
86
src/types/cleaner.ts
Normal 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
20
src/utils/format.ts
Normal 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: "" };
|
||||
}
|
||||
Reference in New Issue
Block a user