diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..5467e21 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -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 diff --git a/src/App.vue b/src/App.vue index 0590fb1..00bfd6c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,1018 +1,112 @@ @@ -1903,4 +984,4 @@ body { width: 18px; height: 18px; } - \ No newline at end of file + diff --git a/src/components/common/AppModal.vue b/src/components/common/AppModal.vue new file mode 100644 index 0000000..0ce11f6 --- /dev/null +++ b/src/components/common/AppModal.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..95774e9 --- /dev/null +++ b/src/components/layout/AppSidebar.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/composables/useAdvancedClean.ts b/src/composables/useAdvancedClean.ts new file mode 100644 index 0000000..72ef840 --- /dev/null +++ b/src/composables/useAdvancedClean.ts @@ -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(null); + const loading = ref>({}); + + 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, + }; +} diff --git a/src/composables/useBrowserClean.ts b/src/composables/useBrowserClean.ts new file mode 100644 index 0000000..8e5f66b --- /dev/null +++ b/src/composables/useBrowserClean.ts @@ -0,0 +1,133 @@ +import { computed, ref } from "vue"; +import { + startBrowserClean as runBrowserCleanCommand, + startBrowserScan as runBrowserScanCommand, +} from "../services/tauri/cleaner"; +import type { AlertOptions, BrowserScanResult, CleanResult } from "../types/cleaner"; +import { formatItemSize } from "../utils/format"; + +interface BrowserState { + isScanning: boolean; + isCleaning: boolean; + isDone: boolean; + scanResult: BrowserScanResult | null; + cleanResult: CleanResult | null; +} + +export function useBrowserClean( + browser: "chrome" | "edge", + showAlert: (options: AlertOptions) => void, +) { + const state = ref({ + isScanning: false, + isCleaning: false, + isDone: false, + scanResult: null, + cleanResult: null, + }); + + const selectedStats = computed(() => { + const scanResult = state.value.scanResult; + if (!scanResult) return { 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 { + sizeStr: formatItemSize(totalBytes), + count: enabledProfiles.length, + hasSelection: enabledProfiles.length > 0, + }; + }); + + 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; + } + + current.isCleaning = true; + try { + current.cleanResult = await runBrowserCleanCommand(browser, selectedProfiles); + current.isDone = true; + current.scanResult = null; + } catch (err) { + showAlert({ + title: "清理失败", + message: String(err), + type: "error", + }); + } finally { + 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, + }; + } + + return { + state, + selectedStats, + startScan, + startClean, + toggleAllProfiles, + invertProfiles, + reset, + }; +} diff --git a/src/composables/useDiskAnalysis.ts b/src/composables/useDiskAnalysis.ts new file mode 100644 index 0000000..e71d184 --- /dev/null +++ b/src/composables/useDiskAnalysis.ts @@ -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([]); + + 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, + }; +} diff --git a/src/composables/useFastClean.ts b/src/composables/useFastClean.ts new file mode 100644 index 0000000..83aa96e --- /dev/null +++ b/src/composables/useFastClean.ts @@ -0,0 +1,119 @@ +import { computed, ref } from "vue"; +import { startFastClean as runFastCleanCommand, startFastScan as runFastScanCommand } from "../services/tauri/cleaner"; +import type { AlertOptions, CleanResult, FastScanResult } from "../types/cleaner"; +import { formatItemSize } from "../utils/format"; + +interface FastState { + isScanning: boolean; + isCleaning: boolean; + isDone: boolean; + progress: number; + scanResult: FastScanResult | null; + cleanResult: CleanResult | null; +} + +export function useFastClean(showAlert: (options: AlertOptions) => void) { + const state = ref({ + isScanning: false, + isCleaning: false, + isDone: false, + progress: 0, + scanResult: null, + cleanResult: null, + }); + + const selectedStats = computed(() => { + const scanResult = state.value.scanResult; + if (!scanResult) return { 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 { + sizeStr: formatItemSize(totalBytes), + count: totalCount, + hasSelection: enabledItems.length > 0, + }; + }); + + 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; + } + + current.isCleaning = true; + try { + current.cleanResult = await runFastCleanCommand(selectedPaths); + current.isDone = true; + current.scanResult = null; + } catch (err) { + showAlert({ + title: "清理失败", + message: String(err), + type: "error", + }); + } finally { + current.isCleaning = false; + } + } + + function reset() { + state.value = { + isScanning: false, + isCleaning: false, + isDone: false, + progress: 0, + scanResult: null, + cleanResult: null, + }; + } + + return { + state, + selectedStats, + startScan, + startClean, + reset, + }; +} diff --git a/src/composables/useMemoryClean.ts b/src/composables/useMemoryClean.ts new file mode 100644 index 0000000..8d37b15 --- /dev/null +++ b/src/composables/useMemoryClean.ts @@ -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({ + 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, + }; +} diff --git a/src/pages/AdvancedCleanPage.vue b/src/pages/AdvancedCleanPage.vue new file mode 100644 index 0000000..2a2fe61 --- /dev/null +++ b/src/pages/AdvancedCleanPage.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/pages/BrowserCleanPage.vue b/src/pages/BrowserCleanPage.vue new file mode 100644 index 0000000..68ce1c2 --- /dev/null +++ b/src/pages/BrowserCleanPage.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/pages/DiskAnalysisPage.vue b/src/pages/DiskAnalysisPage.vue new file mode 100644 index 0000000..329a868 --- /dev/null +++ b/src/pages/DiskAnalysisPage.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/pages/FastCleanPage.vue b/src/pages/FastCleanPage.vue new file mode 100644 index 0000000..f2593c4 --- /dev/null +++ b/src/pages/FastCleanPage.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/pages/MemoryCleanPage.vue b/src/pages/MemoryCleanPage.vue new file mode 100644 index 0000000..76c4bb7 --- /dev/null +++ b/src/pages/MemoryCleanPage.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/services/tauri/cleaner.ts b/src/services/tauri/cleaner.ts new file mode 100644 index 0000000..4caf50f --- /dev/null +++ b/src/services/tauri/cleaner.ts @@ -0,0 +1,81 @@ +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, + ScanProgressPayload, +} from "../../types/cleaner"; + +export function startFastScan() { + return invoke("start_fast_scan"); +} + +export function startFastClean(selectedPaths: string[]) { + return invoke("start_fast_clean", { selectedPaths }); +} + +export function cleanSystemComponents() { + return invoke("clean_system_components"); +} + +export function cleanThumbnails() { + return invoke("clean_thumbnails"); +} + +export function disableHibernation() { + return invoke("disable_hibernation"); +} + +export function startBrowserScan(browser: "chrome" | "edge") { + return invoke("start_browser_scan", { browser }); +} + +export function startBrowserClean(browser: "chrome" | "edge", profiles: string[]) { + return invoke("start_browser_clean", { browser, profiles }); +} + +export function startFullDiskScan() { + return invoke("start_full_disk_scan"); +} + +export function getTreeChildren(path: string) { + return invoke("get_tree_children", { path }); +} + +export function subscribeScanProgress( + handler: (payload: ScanProgressPayload) => void, +) { + return listen("scan-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("get_memory_stats"); +} + +export function runMemoryClean() { + return invoke("run_memory_clean"); +} + +export function runDeepMemoryClean() { + return invoke("run_deep_memory_clean"); +} diff --git a/src/types/cleaner.ts b/src/types/cleaner.ts new file mode 100644 index 0000000..7ca7bc4 --- /dev/null +++ b/src/types/cleaner.ts @@ -0,0 +1,73 @@ +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 type ModalType = "info" | "success" | "error"; + +export interface AlertOptions { + title: string; + message: string; + type?: ModalType; +} diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..1ed0c62 --- /dev/null +++ b/src/utils/format.ts @@ -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: "" }; +}