diff --git a/README.md b/README.md index b8d4585..6b05082 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [项目主页](https://github.com/weidonglang/DevEnv-Manager) · [Release 下载](https://github.com/weidonglang/DevEnv-Manager/releases) · [完整操作手册](docs/user-guide.md) · [环境可靠性设计](docs/env-reliability.md) · [安全说明](docs/safety-and-disclaimer.md) · [问题反馈](https://github.com/weidonglang/DevEnv-Manager/issues) -面向 Windows 的开发环境诊断器与安全操作面板。当前版本:**1.5.3 Stable**。 +面向 Windows 的开发环境诊断器与安全操作面板。当前版本:**1.6 Stable**。 -1.5.3 是基于真实用户反馈的稳定收口版本,重点修复端口误判、外部运行时安全、MySQL 诊断可信度、Python/chsrc 可恢复路径、首次安全声明、白屏兜底、扫描体验和报告脱敏。 +1.6 是基于 v1.5.x 真实复测后的正式稳定版,重点补齐高风险后端确认闭环、页面说明去重与细化、桌面/下载目录分页明细、端口与服务安全保护、MySQL 修复执行保护和正式更新链路。 适合: @@ -37,6 +37,19 @@ DevEnv Manager 解决的是 Windows 上多个开发生态互相影响的问题 - 不适合希望软件自动接管整台机器、清理任意个人文件或替代专业包管理器的场景。 - 熟练使用 mise/asdf/Scoop/Chocolatey 且环境已经稳定的用户,可以只使用诊断能力。 +## 1.6 Stable + +1.6 是 v1.5.x QA 收口后的正式稳定版,重点不是扩展“系统管家”能力,而是把已经验证过的开发环境诊断、安全确认、页面说明和发布链路打通到可公开下载状态。 + +- 高风险操作保护:环境修复/恢复、PATH 清理、项目配置、项目端口、服务管理、Docker/WSL 系统动作、空间搬家/回滚/扩容、缓存清理、进程结束和 MySQL 修复执行均绑定后端 confirmation token。 +- 页面说明:每个页面只保留一个详细“页面使用指南”,合并原功能说明卡里的能做/不会做、确认级别、备份要求和失败处理建议,避免上下重复。 +- 桌面/下载急救:只读分析结果拆成摘要、分类占用分页和 Top 文件分页,文件卡显示文件名、完整路径、所在目录、大小、修改时间、类型、来源和定位状态。 +- 提示体验:右下角进行中提示会保持到操作返回结果;成功/完成提示在结果出现后约 5 秒自动消失,错误提示保持可关闭。 +- 系统级入口:Docker、WSL、本地服务和自卸载入口默认折叠在高级区,系统关键进程不提供结束入口。 +- 发布链路:更新清单、Release asset、SHA256 和 README 同步到 1.6 Stable。 + +兼容说明:Tauri identifier 继续保留 `com.weidonglang.dailytools`,Rust/npm 包名继续保留 `dailytools-tauri`,用于兼容旧安装、升级路径和本地配置目录;产品展示名和 Release asset 统一使用 DevEnv Manager。 + ## 1.5.3 Stable 1.5.3 是质量补丁与稳定版收口,重点不是扩展系统管家能力,而是补齐已反馈问题的安全边界、误判抑制、恢复入口和发布链路。 diff --git a/tauri/package-lock.json b/tauri/package-lock.json index c9d9613..3cee230 100644 --- a/tauri/package-lock.json +++ b/tauri/package-lock.json @@ -1,12 +1,12 @@ { "name": "dailytools-tauri", - "version": "1.5.3", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dailytools-tauri", - "version": "1.5.3", + "version": "1.6.0", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/tauri/package.json b/tauri/package.json index b1caa7a..46d3238 100644 --- a/tauri/package.json +++ b/tauri/package.json @@ -1,7 +1,7 @@ { "name": "dailytools-tauri", "private": true, - "version": "1.5.3", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1 --port 1420", diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 0df520e..b9333f3 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -480,7 +480,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "dailytools-tauri" -version = "1.5.3" +version = "1.6.0" dependencies = [ "dirs", "reqwest 0.12.28", diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index 122c4f4..19ac4a5 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dailytools-tauri" -version = "1.5.3" +version = "1.6.0" description = "A lightweight Tauri rewrite of DevEnv Manager." authors = ["weidonglang"] edition = "2021" diff --git a/tauri/src-tauri/src/cleanup/app_usage.rs b/tauri/src-tauri/src/cleanup/app_usage.rs index 62a03f5..717bbbd 100644 --- a/tauri/src-tauri/src/cleanup/app_usage.rs +++ b/tauri/src-tauri/src/cleanup/app_usage.rs @@ -32,6 +32,7 @@ pub(crate) fn usage_item( size, category: category.to_string(), suggestion: "只展示占用;请通过应用内置设置备份、迁移或清理".to_string(), + details: Vec::new(), }); } let size = categories.iter().map(|item| item.size).sum(); diff --git a/tauri/src-tauri/src/cleanup/architecture.rs b/tauri/src-tauri/src/cleanup/architecture.rs index 1e14447..91a353b 100644 --- a/tauri/src-tauri/src/cleanup/architecture.rs +++ b/tauri/src-tauri/src/cleanup/architecture.rs @@ -91,7 +91,7 @@ pub fn architecture() -> CleanupArchitecture { }, ], safety_rules: vec![ - "Phase 2 必须经过扫描、选择、计划预览、二次确认、重新校验、清理和报告", + "清理必须经过扫描、选择、计划预览、二次确认、重新校验、执行和报告", "普通文件优先移入 Windows 回收站;开发缓存只调用工具官方命令", "系统目录、用户文档、当前项目和受管运行时始终受保护", "默认扫描不进入桌面、下载、文档、图片、视频或音乐目录", @@ -99,7 +99,7 @@ pub fn architecture() -> CleanupArchitecture { "浏览器 Cookie、登录数据和密码存储不会进入扫描结果", "微信、QQ 数据库和符号链接会被跳过", "权限不足或扫描上限触发时只记录警告,不尝试提权", - "Phase 3 的桌面、下载、重复文件、应用、软件和游戏能力只展示占用与建议", + "桌面、下载、重复文件、应用、软件和游戏分析默认只展示占用与建议", ], } } diff --git a/tauri/src-tauri/src/cleanup/downloads.rs b/tauri/src-tauri/src/cleanup/downloads.rs index 6af88eb..e6780a8 100644 --- a/tauri/src-tauri/src/cleanup/downloads.rs +++ b/tauri/src-tauri/src/cleanup/downloads.rs @@ -1,4 +1,4 @@ -use super::model::{FolderUsageItem, FolderUsageReport}; +use super::model::{FolderUsageItem, FolderUsageReport, LargeFileItem}; use super::protect::is_sensitive_account_data; use super::utils::system_time_string; use std::collections::HashMap; @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; const MAX_FOLDER_ENTRIES: usize = 100_000; +type FileRecord = (PathBuf, u64, Option); pub(crate) fn classify_file_type(path: &Path) -> &'static str { let extension = path @@ -26,7 +27,7 @@ pub(crate) fn classify_file_type(path: &Path) -> &'static str { } } -fn collect_files(root: &Path) -> (Vec<(PathBuf, u64, Option)>, bool) { +fn collect_files(root: &Path) -> (Vec, bool) { let mut files = Vec::new(); let mut stack = vec![root.to_path_buf()]; let mut visited = 0_usize; @@ -55,13 +56,126 @@ fn collect_files(root: &Path) -> (Vec<(PathBuf, u64, Option)>, bool) (files, truncated) } -fn category_item(root: &Path, name: &str, size: u64, suggestion: &str) -> FolderUsageItem { +fn source_label(desktop: bool, category: &str) -> String { + if desktop { + if category == "截图" { + "桌面 / 截图".to_string() + } else { + format!("桌面 / {category}") + } + } else { + format!("下载 / {category}") + } +} + +fn file_item(path: &Path, size: u64, modified: Option, source_category: &str) -> LargeFileItem { + let exists = path.exists(); + let directory = path + .parent() + .map(|value| value.to_string_lossy().to_string()) + .unwrap_or_default(); + let can_locate = !directory.is_empty() && Path::new(&directory).exists(); + let file_type = classify_file_type(path).to_string(); + LargeFileItem { + file_name: path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_string(), + path: path.to_string_lossy().to_string(), + directory, + extension: path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_string(), + size, + modified_at: modified.and_then(system_time_string), + file_type: file_type.clone(), + source_category: source_category.to_string(), + exists, + can_open: exists, + can_locate, + open_status: if exists { + "文件存在,可在资源管理器中定位".to_string() + } else if can_locate { + "文件已移动或删除,请重新扫描".to_string() + } else { + "所在目录不可访问,请检查权限、云盘同步或重新扫描".to_string() + }, + suggestion: if file_type == "安装包" || file_type == "压缩包" || file_type == "ISO/磁盘镜像" { + "确认不再需要后可加入归档计划;本页面不会自动删除或移动".to_string() + } else { + "先定位文件并确认用途;本页面只提供只读分析".to_string() + }, + risk: if size >= 1024 * 1024 * 1024 { + "medium".to_string() + } else { + "low".to_string() + }, + } +} + +fn is_screenshot(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + name.contains("screenshot") + || name.contains("screen shot") + || name.contains("截图") + || name.contains("截屏") +} + +fn file_matches_category( + name: &str, + path: &Path, + size: u64, + modified: Option, + desktop: bool, + now: SystemTime, + same_size: &HashMap, +) -> bool { + match name { + "超过 1GB" => size >= 1024 * 1024 * 1024, + "超过 30 天未修改" => modified.is_some_and(|time| { + now.duration_since(time).unwrap_or(Duration::ZERO) + >= Duration::from_secs(30 * 24 * 60 * 60) + }), + "截图" => desktop && is_screenshot(path), + "重复文件候选" => desktop && size > 0 && same_size.get(&size).copied().unwrap_or(0) > 1, + _ => classify_file_type(path) == name, + } +} + +fn category_details( + files: &[FileRecord], + name: &str, + desktop: bool, + now: SystemTime, + same_size: &HashMap, +) -> Vec { + let mut details: Vec<_> = files + .iter() + .filter(|(path, size, modified)| { + file_matches_category(name, path, *size, *modified, desktop, now, same_size) + }) + .map(|(path, size, modified)| file_item(path, *size, *modified, &source_label(desktop, name))) + .collect(); + details.sort_by_key(|item| std::cmp::Reverse(item.size)); + details.truncate(10); + details +} + +fn category_item(root: &Path, name: &str, size: u64, suggestion: &str, details: Vec) -> FolderUsageItem { FolderUsageItem { name: name.to_string(), path: root.to_string_lossy().to_string(), size, category: name.to_string(), suggestion: suggestion.to_string(), + details, } } @@ -83,25 +197,15 @@ pub(crate) fn inspect_folder(root: &Path, desktop: bool) -> FolderUsageReport { if *size >= 1024 * 1024 * 1024 { *sizes.entry("超过 1GB").or_default() += *size; } - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if desktop - && (name.contains("screenshot") - || name.contains("screen shot") - || name.contains("截图") - || name.contains("截屏")) - { + if is_screenshot(path) && desktop { *sizes.entry("截图").or_default() += *size; } } if desktop { let reclaimable = same_size - .into_iter() - .filter(|(size, count)| *size > 0 && *count > 1) - .map(|(size, count)| size.saturating_mul((count - 1) as u64)) + .iter() + .filter(|(size, count)| **size > 0 && **count > 1) + .map(|(size, count)| (*size).saturating_mul((*count - 1) as u64)) .sum(); sizes.insert("重复文件候选", reclaimable); } @@ -132,6 +236,20 @@ pub(crate) fn inspect_folder(root: &Path, desktop: bool) -> FolderUsageReport { "其他", ] }; + let mut top_files: Vec<_> = files + .iter() + .map(|(path, size, modified)| { + file_item( + path, + *size, + *modified, + if desktop { "桌面 / Top 文件" } else { "下载 / Top 文件" }, + ) + }) + .collect(); + top_files.sort_by_key(|item| std::cmp::Reverse(item.size)); + top_files.truncate(20); + let categories = order .into_iter() .filter_map(|name| { @@ -148,6 +266,7 @@ pub(crate) fn inspect_folder(root: &Path, desktop: bool) -> FolderUsageReport { } else { "按类型查看并决定是否归档;本阶段只提供建议" }, + category_details(&files, name, desktop, now, &same_size), ) }) }) @@ -162,6 +281,7 @@ pub(crate) fn inspect_folder(root: &Path, desktop: bool) -> FolderUsageReport { path: root.to_string_lossy().to_string(), total_bytes, categories, + top_files, suggestions: vec![ "本阶段只生成整理建议,不删除或移动桌面/下载文件".to_string(), "旧安装包在归档前应确认对应软件已安装且安装包可重新获取".to_string(), diff --git a/tauri/src-tauri/src/cleanup/junction.rs b/tauri/src-tauri/src/cleanup/junction.rs index e8e7eb8..02d1752 100644 --- a/tauri/src-tauri/src/cleanup/junction.rs +++ b/tauri/src-tauri/src/cleanup/junction.rs @@ -1,6 +1,6 @@ //! Junction-specific entry points live in `move_plan`/`migration`. //! -//! This module exists to keep the cleanup namespace aligned with the Phase 4 -//! architecture. The actual implementation is intentionally centralized so +//! This module keeps the cleanup namespace aligned with migration architecture. +//! The actual implementation is intentionally centralized so //! source validation, copy verification, rollback records and reports cannot //! diverge between direct Junction creation and regular move plans. diff --git a/tauri/src-tauri/src/cleanup/large_files.rs b/tauri/src-tauri/src/cleanup/large_files.rs index 1c1254a..6206b68 100644 --- a/tauri/src-tauri/src/cleanup/large_files.rs +++ b/tauri/src-tauri/src/cleanup/large_files.rs @@ -21,6 +21,55 @@ fn normalized(path: &Path) -> String { .to_ascii_lowercase() } +fn large_file_item(path: &Path, size: u64, modified_at: Option, file_type: String) -> LargeFileItem { + let directory = path + .parent() + .map(|value| value.to_string_lossy().to_string()) + .unwrap_or_default(); + let exists = path.exists(); + let can_locate = !directory.is_empty() && Path::new(&directory).exists(); + LargeFileItem { + file_name: path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_string(), + path: path.to_string_lossy().to_string(), + directory, + extension: path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_string(), + size, + modified_at, + source_category: format!("大文件 / {file_type}"), + exists, + can_open: exists, + can_locate, + open_status: if exists { + "文件存在,可在资源管理器中定位".to_string() + } else if can_locate { + "文件已移动或删除,请重新扫描".to_string() + } else { + "所在目录不可访问,请检查权限、云盘同步或重新扫描".to_string() + }, + suggestion: match file_type.as_str() { + "安装包" | "压缩包" | "ISO/磁盘镜像" => "确认不再需要后可加入归档计划", + "视频" => "建议移动到空间充足的数据盘或媒体库", + _ => "先打开所在目录确认用途;本阶段不删除", + } + .to_string(), + risk: if size >= 5 * 1024 * 1024 * 1024 { + "high" + } else { + "medium" + } + .to_string(), + file_type, + } +} + pub(crate) fn validate_analysis_root(root: &Path) -> Result<(), String> { if !root.is_dir() { return Err("扫描目录不存在".to_string()); @@ -89,26 +138,12 @@ where if metadata.is_file() { if metadata.len() >= min_bytes { let file_type = classify_file_type(&path).to_string(); - result.push(LargeFileItem { - path: path.to_string_lossy().to_string(), - size: metadata.len(), - modified_at: metadata.modified().ok().and_then(system_time_string), - suggestion: match file_type.as_str() { - "安装包" | "压缩包" | "ISO/磁盘镜像" => { - "确认不再需要后可在 Phase 4 加入归档计划" - } - "视频" => "建议移动到空间充足的数据盘或媒体库", - _ => "先打开所在目录确认用途;本阶段不删除", - } - .to_string(), - risk: if metadata.len() >= 5 * 1024 * 1024 * 1024 { - "high" - } else { - "medium" - } - .to_string(), + result.push(large_file_item( + &path, + metadata.len(), + metadata.modified().ok().and_then(system_time_string), file_type, - }); + )); } } else if let Ok(entries) = fs::read_dir(&path) { stack.extend(entries.flatten().map(|entry| entry.path())); diff --git a/tauri/src-tauri/src/cleanup/model.rs b/tauri/src-tauri/src/cleanup/model.rs index 6e46baf..890fc17 100644 --- a/tauri/src-tauri/src/cleanup/model.rs +++ b/tauri/src-tauri/src/cleanup/model.rs @@ -126,10 +126,18 @@ pub struct CleanupFailure { #[derive(Debug, Clone, Serialize, Default)] #[serde(rename_all = "camelCase")] pub struct LargeFileItem { + pub file_name: String, pub path: String, + pub directory: String, + pub extension: String, pub size: u64, pub modified_at: Option, pub file_type: String, + pub source_category: String, + pub exists: bool, + pub can_open: bool, + pub can_locate: bool, + pub open_status: String, pub suggestion: String, pub risk: String, } @@ -159,6 +167,7 @@ pub struct FolderUsageItem { pub size: u64, pub category: String, pub suggestion: String, + pub details: Vec, } #[derive(Debug, Clone, Serialize, Default)] @@ -168,6 +177,7 @@ pub struct FolderUsageReport { pub path: String, pub total_bytes: u64, pub categories: Vec, + pub top_files: Vec, pub suggestions: Vec, pub warnings: Vec, } diff --git a/tauri/src-tauri/src/cleanup/report.rs b/tauri/src-tauri/src/cleanup/report.rs index e43ff67..f74b1c8 100644 --- a/tauri/src-tauri/src/cleanup/report.rs +++ b/tauri/src-tauri/src/cleanup/report.rs @@ -91,11 +91,11 @@ pub fn inspect_maintenance_overview(managed_root: &Path) -> Result "C 盘空间偏紧,建议优先查看安全清理估算与开发缓存。", "medium" => "C 盘空间需要关注,可以安排清理缓存或迁移大目录。", "low" => "C 盘空间目前健康,仍可定期扫描并按计划执行保守清理。", - _ => "暂未识别 C 盘容量,请确认磁盘卷可被 Windows 正常读取。", + _ => "未能识别 C 盘容量,请确认磁盘卷可被 Windows 正常读取。", } .to_string(); let mut suggestions = vec![ - "先扫描并预览清理计划;Phase 2 只处理用户明确选择且再次校验通过的项目。".to_string(), + "先扫描并预览清理计划;只处理用户明确选择且再次校验通过的项目。".to_string(), "出于安全边界,默认扫描不会进入桌面、下载、文档或其他个人资料目录。".to_string(), ]; if dev_cache_estimate > 1024 * 1024 * 1024 { diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs index 2689654..fe9e706 100644 --- a/tauri/src-tauri/src/lib.rs +++ b/tauri/src-tauri/src/lib.rs @@ -219,6 +219,7 @@ struct KillResult { #[serde(rename_all = "camelCase")] struct ConfirmationToken { token: String, + command: String, action_id: String, plan_id: String, risk_level: String, @@ -234,6 +235,7 @@ struct ConfirmationToken { #[serde(rename_all = "camelCase")] struct ConfirmationTokenView { token: String, + command: String, action_id: String, plan_id: String, risk_level: String, @@ -953,8 +955,179 @@ fn confirmation_tokens() -> &'static Mutex> { CONFIRMATION_TOKENS.get_or_init(|| Mutex::new(HashMap::new())) } +const RISK_OPERATION_REGISTRY: &[RiskOperationSpec] = &[ + RiskOperationSpec { + command: "apply_env_repair_plan", + action_id: "apply_env_repair_plan", + risk_level: "high", + requires_backup: true, + requires_token: true, + description: "写入用户级环境修复计划", + }, + RiskOperationSpec { + command: "restore_user_environment", + action_id: "restore_user_environment", + risk_level: "high", + requires_backup: true, + requires_token: true, + description: "恢复用户环境变量备份", + }, + RiskOperationSpec { + command: "cleanup_path_entries", + action_id: "cleanup_path_entries", + risk_level: "medium", + requires_backup: true, + requires_token: true, + description: "清理用户 PATH 失效或重复条目", + }, + RiskOperationSpec { + command: "apply_user_environment_configuration", + action_id: "apply_user_environment_configuration", + risk_level: "high", + requires_backup: true, + requires_token: true, + description: "写入用户级 DEVENV_HOME/JAVA_HOME/PATH 配置", + }, + RiskOperationSpec { + command: "manage_system_platform", + action_id: "manage_system_platform", + risk_level: "high", + requires_backup: false, + requires_token: true, + description: "安装、升级、退出 Docker/WSL 等系统平台操作", + }, + RiskOperationSpec { + command: "manage_local_service", + action_id: "manage_local_service", + risk_level: "high", + requires_backup: false, + requires_token: true, + description: "启动、停止或重启本地数据库 Windows 服务", + }, + RiskOperationSpec { + command: "stop_local_service", + action_id: "stop_local_service", + risk_level: "high", + requires_backup: false, + requires_token: true, + description: "停止占用数据库端口的 Windows 服务", + }, + RiskOperationSpec { + command: "apply_project_configuration", + action_id: "apply_project_configuration", + risk_level: "high", + requires_backup: true, + requires_token: true, + description: "写入项目配置并可切换运行时", + }, + RiskOperationSpec { + command: "update_project_port", + action_id: "update_project_port", + risk_level: "medium", + requires_backup: true, + requires_token: true, + description: "备份并修改项目端口配置", + }, + RiskOperationSpec { + command: "rollback_move", + action_id: "rollback_move", + risk_level: "high", + requires_backup: false, + requires_token: true, + description: "回滚空间搬家或 Junction 操作", + }, + RiskOperationSpec { + command: "execute_move_plan", + action_id: "execute_move_plan", + risk_level: "high", + requires_backup: true, + requires_token: true, + description: "执行空间搬家或归档计划", + }, + RiskOperationSpec { + command: "execute_expansion_plan", + action_id: "execute_expansion_plan", + risk_level: "critical", + requires_backup: true, + requires_token: true, + description: "执行磁盘扩容计划", + }, + RiskOperationSpec { + command: "clear_download_cache", + action_id: "clear_download_cache", + risk_level: "medium", + requires_backup: false, + requires_token: true, + description: "将下载缓存移入回收站", + }, + RiskOperationSpec { + command: "clean_dev_cache", + action_id: "clean_dev_cache", + risk_level: "medium", + requires_backup: false, + requires_token: true, + description: "调用官方命令清理开发缓存", + }, + RiskOperationSpec { + command: "kill_process", + action_id: "kill_process", + risk_level: "high", + requires_backup: false, + requires_token: true, + description: "结束进程及子进程", + }, + RiskOperationSpec { + command: "execute_mysql_repair_plan", + action_id: "execute_mysql_repair_plan", + risk_level: "critical", + requires_backup: true, + requires_token: true, + description: "执行 MySQL 高危修复计划", + }, +]; + +fn risk_operation_spec(command: &str) -> Option { + RISK_OPERATION_REGISTRY + .iter() + .copied() + .find(|item| item.command == command) +} + +fn risk_operation_fingerprint(command: &str, plan_id: &str, risk_level: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(command.as_bytes()); + hasher.update(b"\0"); + hasher.update(plan_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(risk_level.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn require_risk_operation_token( + command: &str, + plan_id: &str, + confirmation_token: Option, +) -> Result<(), String> { + let spec = risk_operation_spec(command).ok_or_else(|| format!("高危操作未登记:{command}"))?; + if !spec.requires_token { + return Ok(()); + } + let fingerprint = risk_operation_fingerprint(command, plan_id, spec.risk_level); + require_confirmation_token( + confirmation_token, + command, + spec.action_id, + plan_id, + spec.risk_level, + &fingerprint, + spec.requires_backup, + ) + .map_err(|message| format!("{message}(操作:{})", spec.description)) +} + #[tauri::command] fn create_confirmation_token( + command: Option, action_id: String, plan_id: String, risk_level: String, @@ -962,10 +1135,18 @@ fn create_confirmation_token( triple_confirmed: bool, backup_receipt: Option, ) -> Result { + let command = command + .unwrap_or_else(|| action_id.clone()) + .trim() + .to_string(); + let command = validate_confirmation_field(command, "command")?; let action_id = validate_confirmation_field(action_id, "action_id")?; let plan_id = validate_confirmation_field(plan_id, "plan_id")?; let risk_level = validate_confirmation_field(risk_level, "risk_level")?; let plan_fingerprint = validate_confirmation_field(plan_fingerprint, "plan_fingerprint")?; + if risk_operation_spec(&command).is_none() && command != action_id { + return Err("confirmation token command 未登记".to_string()); + } if risk_level == "critical" && !triple_confirmed { return Err("极高风险操作必须完成三次确认".to_string()); } @@ -974,6 +1155,8 @@ fn create_confirmation_token( let mut hasher = Sha256::new(); hasher.update(action_id.as_bytes()); hasher.update(b"\0"); + hasher.update(command.as_bytes()); + hasher.update(b"\0"); hasher.update(plan_id.as_bytes()); hasher.update(b"\0"); hasher.update(plan_fingerprint.as_bytes()); @@ -983,6 +1166,7 @@ fn create_confirmation_token( let token = format!("{:x}", hasher.finalize()); let record = ConfirmationToken { token: token.clone(), + command: command.clone(), action_id: action_id.clone(), plan_id: plan_id.clone(), risk_level: risk_level.clone(), @@ -1000,6 +1184,7 @@ fn create_confirmation_token( store.insert(token.clone(), record); Ok(ConfirmationTokenView { token, + command, action_id, plan_id, risk_level, @@ -1017,6 +1202,7 @@ fn validate_confirmation_field(value: String, label: &str) -> Result, + command: &str, action_id: &str, plan_id: &str, risk_level: &str, @@ -1041,7 +1227,8 @@ fn require_confirmation_token( record.used = true; return Err("confirmation token 已过期,请重新确认".to_string()); } - if record.action_id != action_id + if record.command != command + || record.action_id != action_id || record.plan_id != plan_id || record.risk_level != risk_level || record.plan_fingerprint != plan_fingerprint @@ -1236,8 +1423,13 @@ async fn clean_managed_download_cache() -> Result Result { +async fn clean_dev_cache( + tool: String, + confirmation_token: Option, +) -> Result { run_blocking(move || { + let plan_id = format!("tool-{}", tool.trim().to_ascii_lowercase()); + require_risk_operation_token("clean_dev_cache", &plan_id, confirmation_token)?; let paths = load_paths()?; let message = cleanup::clean_dev_cache(&tool, &paths.root)?; Ok(OperationResult { @@ -1468,8 +1660,10 @@ async fn create_env_repair_plan( #[tauri::command] async fn apply_env_repair_plan( plan: env_core::EnvRepairPlan, + confirmation_token: Option, ) -> Result { run_blocking(move || { + require_risk_operation_token("apply_env_repair_plan", &plan.plan_id, confirmation_token)?; let paths = load_paths()?; Ok(env_core::apply_env_repair_plan(&paths.root, plan)) }) @@ -1510,11 +1704,27 @@ async fn create_java_stabilize_plan(jdk_path: String) -> Result Result, String> { + run_blocking(move || verify_external_jdk_blocking(jdk_path)).await? +} + #[tauri::command] async fn apply_java_stabilize_plan( plan: env_core::EnvRepairPlan, + confirmation_token: Option, ) -> Result { - apply_env_repair_plan(plan).await + apply_env_repair_plan(plan, confirmation_token).await } #[tauri::command] @@ -1526,6 +1736,80 @@ async fn verify_java_toolchain() -> Result Result, String> { + let root = PathBuf::from(jdk_path.trim()); + if jdk_path.trim().is_empty() { + return Err("请先选择 JDK 根目录".to_string()); + } + if root + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("bin")) + { + return Err("请填写 JDK 根目录,不能填写 bin 目录".to_string()); + } + if !root.is_dir() { + return Err("JDK 根目录不存在".to_string()); + } + let exe_suffix = if cfg!(windows) { ".exe" } else { "" }; + Ok(vec![ + verify_jdk_tool( + "java-version", + "java -version", + &root.join("bin").join(format!("java{exe_suffix}")), + &["-version"], + ), + verify_jdk_tool( + "javac-version", + "javac -version", + &root.join("bin").join(format!("javac{exe_suffix}")), + &["-version"], + ), + verify_jdk_tool( + "jar-version", + "jar --version", + &root.join("bin").join(format!("jar{exe_suffix}")), + &["--version"], + ), + ]) +} + +fn verify_jdk_tool(id: &str, title: &str, executable: &Path, args: &[&str]) -> ValidationCheck { + if !executable.is_file() { + return ValidationCheck { + id: id.to_string(), + title: title.to_string(), + success: false, + required: true, + detail: format!("未找到 {}", display_path(executable)), + stage: "external-jdk".to_string(), + }; + } + match hidden_command(executable).args(args).output() { + Ok(output) => { + let detail = + first_meaningful_output_line(&command_text(&output.stdout, &output.stderr)) + .unwrap_or_else(|| "命令没有返回版本文本".to_string()); + ValidationCheck { + id: id.to_string(), + title: title.to_string(), + success: output.status.success(), + required: true, + detail, + stage: "external-jdk".to_string(), + } + } + Err(error) => ValidationCheck { + id: id.to_string(), + title: title.to_string(), + success: false, + required: true, + detail: format!("执行失败:{error}"), + stage: "external-jdk".to_string(), + }, + } +} + #[tauri::command] async fn verify_nacos_java_environment( nacos_root: String, @@ -1653,8 +1937,12 @@ async fn create_move_plan( } #[tauri::command] -async fn execute_move_plan(plan: cleanup::MovePlan) -> Result { +async fn execute_move_plan( + plan: cleanup::MovePlan, + confirmation_token: Option, +) -> Result { run_blocking(move || { + require_risk_operation_token("execute_move_plan", &plan.plan_id, confirmation_token)?; let paths = load_paths()?; Ok(cleanup::execute_move_plan(&paths.root, plan)) }) @@ -1671,8 +1959,12 @@ async fn list_rollback_records() -> Result, String> } #[tauri::command] -async fn rollback_move(rollback_id: String) -> Result { +async fn rollback_move( + rollback_id: String, + confirmation_token: Option, +) -> Result { run_blocking(move || { + require_risk_operation_token("rollback_move", &rollback_id, confirmation_token)?; let paths = load_paths()?; let message = cleanup::rollback_move(&paths.root, rollback_id)?; Ok(OperationResult { @@ -1703,8 +1995,10 @@ async fn create_desktop_archive_plan(target_drive: String) -> Result, ) -> Result { run_blocking(move || { + require_risk_operation_token("execute_move_plan", &plan.plan_id, confirmation_token)?; let paths = load_paths()?; Ok(cleanup::execute_desktop_archive_plan(&paths.root, plan)) }) @@ -1719,8 +2013,10 @@ async fn create_downloads_archive_plan(target_drive: String) -> Result, ) -> Result { run_blocking(move || { + require_risk_operation_token("execute_move_plan", &plan.plan_id, confirmation_token)?; let paths = load_paths()?; Ok(cleanup::execute_downloads_archive_plan(&paths.root, plan)) }) @@ -1740,8 +2036,13 @@ async fn create_c_drive_expansion_plan() -> Result, ) -> Result { - run_blocking(move || Ok(cleanup::execute_c_drive_expansion(plan))).await? + run_blocking(move || { + require_risk_operation_token("execute_expansion_plan", &plan.plan_id, confirmation_token)?; + Ok(cleanup::execute_c_drive_expansion(plan)) + }) + .await? } #[tauri::command] @@ -2157,7 +2458,22 @@ fn preview_user_environment_configuration() -> Result Result { +fn apply_user_environment_configuration( + preview_id: String, + confirmation_token: Option, +) -> Result { + apply_user_environment_configuration_with_token(preview_id, confirmation_token) +} + +fn apply_user_environment_configuration_with_token( + preview_id: String, + confirmation_token: Option, +) -> Result { + require_risk_operation_token( + "apply_user_environment_configuration", + &preview_id, + confirmation_token, + )?; let pending = environment_preview_store() .lock() .map_err(|_| "环境配置预览暂时不可用".to_string())? @@ -2254,8 +2570,18 @@ fn configure_user_environment_blocking() -> Result { } #[tauri::command] -async fn cleanup_path_entries() -> Result { - run_blocking(cleanup_path_entries_blocking).await? +async fn cleanup_path_entries( + confirmation_token: Option, +) -> Result { + run_blocking(move || { + require_risk_operation_token( + "cleanup_path_entries", + "cleanup-path-entries", + confirmation_token, + )?; + cleanup_path_entries_blocking() + }) + .await? } fn cleanup_path_entries_blocking() -> Result { @@ -2311,8 +2637,18 @@ fn cleanup_path_entries_blocking() -> Result { } #[tauri::command] -async fn restore_user_environment() -> Result { - run_blocking(restore_user_environment_blocking).await? +async fn restore_user_environment( + confirmation_token: Option, +) -> Result { + run_blocking(move || { + require_risk_operation_token( + "restore_user_environment", + "restore-user-environment-latest", + confirmation_token, + )?; + restore_user_environment_blocking() + }) + .await? } fn restore_user_environment_blocking() -> Result { @@ -3290,6 +3626,7 @@ fn kill_process( if let Err(message) = require_confirmation_token( confirmation_token, "kill_process", + "kill_process", &plan_id, risk_level, &fingerprint, @@ -3514,9 +3851,14 @@ async fn update_project_port( path: String, config_id: String, new_port: u16, + confirmation_token: Option, ) -> Result { - run_blocking(move || update_project_port_blocking(Path::new(path.trim()), &config_id, new_port)) - .await? + run_blocking(move || { + let plan_id = format!("{}:{config_id}:{new_port}", path.trim()); + require_risk_operation_token("update_project_port", &plan_id, confirmation_token)?; + update_project_port_blocking(Path::new(path.trim()), &config_id, new_port) + }) + .await? } fn inspect_project_port_configs_blocking(root: &Path) -> Result, String> { @@ -3991,7 +4333,7 @@ fn add_archive_plan_item(path: String, source: String) -> Result Result { } #[tauri::command] -fn clear_download_cache() -> Result { +fn clear_download_cache(confirmation_token: Option) -> Result { + require_risk_operation_token( + "clear_download_cache", + "clear-download-cache", + confirmation_token, + )?; let paths = load_paths()?; let result = cleanup::clean_managed_download_cache(&paths.root); Ok(OperationResult { @@ -6188,8 +6535,14 @@ async fn manage_system_platform( app: tauri::AppHandle, action: String, value: Option, + confirmation_token: Option, ) -> Result { - run_blocking(move || manage_system_platform_blocking(app, action, value)).await? + run_blocking(move || { + let plan_id = format!("{}:{}", action.trim(), value.as_deref().unwrap_or("")); + require_risk_operation_token("manage_system_platform", &plan_id, confirmation_token)?; + manage_system_platform_blocking(app, action, value) + }) + .await? } fn manage_system_platform_blocking( @@ -6412,8 +6765,17 @@ fn inspect_local_services_blocking() -> Result, String> } #[tauri::command] -async fn stop_local_service(port: u16, service_name: String) -> Result { - run_blocking(move || stop_local_service_blocking(port, service_name)).await? +async fn stop_local_service( + port: u16, + service_name: String, + confirmation_token: Option, +) -> Result { + run_blocking(move || { + let plan_id = format!("{port}:{}", service_name.trim()); + require_risk_operation_token("stop_local_service", &plan_id, confirmation_token)?; + stop_local_service_blocking(port, service_name) + }) + .await? } fn stop_local_service_blocking(port: u16, service_name: String) -> Result { @@ -6504,8 +6866,14 @@ fn validated_database_service(name: &str) -> Result<(WindowsServiceInfo, u16), S async fn manage_local_service( service_name: String, action: String, + confirmation_token: Option, ) -> Result { - run_blocking(move || manage_local_service_blocking(service_name, action)).await? + run_blocking(move || { + let plan_id = format!("{}:{}", service_name.trim(), action.trim()); + require_risk_operation_token("manage_local_service", &plan_id, confirmation_token)?; + manage_local_service_blocking(service_name, action) + }) + .await? } fn manage_local_service_blocking( @@ -6689,6 +7057,7 @@ async fn execute_mysql_repair_plan( if guard.risk_level != "low" { require_confirmation_token( confirmation_token, + "execute_mysql_repair_plan", &guard.action_id, &guard.plan_id, &guard.risk_level, @@ -8053,10 +8422,40 @@ fn restore_project_files(changes: &[(PathBuf, Option)]) { } } +fn project_configuration_plan_id(request: &ProjectConfigApplyRequest) -> String { + let enabled = request.files.iter().filter(|file| file.enabled).count(); + let switch_count = [ + &request.switches.jdk, + &request.switches.python, + &request.switches.node, + &request.switches.maven, + &request.switches.gradle, + &request.switches.go, + ] + .iter() + .filter(|value| value.is_some()) + .count(); + format!( + "{}:{enabled}:{switch_count}", + request + .project_path + .trim() + .replace('/', "\\") + .to_ascii_lowercase() + ) +} + #[tauri::command] fn apply_project_configuration( request: ProjectConfigApplyRequest, + confirmation_token: Option, ) -> Result { + let project_plan_id = project_configuration_plan_id(&request); + require_risk_operation_token( + "apply_project_configuration", + &project_plan_id, + confirmation_token, + )?; let root = PathBuf::from(request.project_path.trim()); analyze_project_blocking(&root)?; if request.files.len() > 4 { @@ -8172,15 +8571,11 @@ fn apply_project_configuration( #[tauri::command] fn generate_vscode_config(project_path: String) -> Result { - let preview = preview_project_configuration(project_path.clone())?; - apply_project_configuration(ProjectConfigApplyRequest { - project_path, - files: preview - .files - .into_iter() - .filter(|file| file.relative_path.starts_with(".vscode/")) - .collect(), - switches: CurrentVersions::default(), + let _ = preview_project_configuration(project_path)?; + Ok(OperationResult { + success: false, + message: "请在项目页先生成配置预览,再通过受保护的应用流程写入;直接写入入口已禁用。" + .to_string(), }) } @@ -8213,6 +8608,7 @@ pub fn run() { rollback_env_repair, export_env_reliability_report, create_java_stabilize_plan, + verify_external_jdk, apply_java_stabilize_plan, verify_java_toolchain, verify_nacos_java_environment, @@ -10892,6 +11288,9 @@ fn analyze_port_signature( "wechat", "weixin", "wxwork", + "bilibili", + "哔哩哔哩", + "uu", "chrome.exe", "msedge.exe", "firefox.exe", @@ -10905,9 +11304,26 @@ fn analyze_port_signature( "code.exe", "wechat.exe", "webview", + "mumu", + "nemu", + "armourycrate", + "autodesk", ], "桌面应用", ), + ( + "系统/驱动服务", + &[ + "system", + "svchost.exe", + "vmware-authd.exe", + "nvcontainer.exe", + "nvidia overlay.exe", + "avp.exe", + "kaspersky", + ], + "系统/驱动服务", + ), ]; let is_generic_signature = |label: &str| { matches!( @@ -12905,6 +13321,7 @@ mod tests { let plan_id = format!("test-plan-{}", unix_timestamp()); let fingerprint = process_action_fingerprint("kill_process", &plan_id, "high"); let token = create_confirmation_token( + Some("kill_process".to_string()), "kill_process".to_string(), plan_id.clone(), "high".to_string(), @@ -12916,6 +13333,7 @@ mod tests { assert!(require_confirmation_token( Some(token.token.clone()), "kill_process", + "kill_process", &plan_id, "high", &fingerprint, @@ -12925,6 +13343,7 @@ mod tests { assert!(require_confirmation_token( Some(token.token), "kill_process", + "kill_process", &plan_id, "high", &fingerprint, @@ -12933,6 +13352,84 @@ mod tests { .is_err()); } + #[test] + fn risk_gate_rejects_missing_mismatch_expired_and_reused_tokens() { + let command = "manage_system_platform"; + let plan_id = "docker_update:"; + assert!(require_risk_operation_token(command, plan_id, None).is_err()); + + let fingerprint = risk_operation_fingerprint(command, plan_id, "high"); + let token = create_confirmation_token( + Some(command.to_string()), + command.to_string(), + plan_id.to_string(), + "high".to_string(), + fingerprint, + false, + None, + ) + .unwrap(); + + assert!(require_risk_operation_token( + "execute_move_plan", + plan_id, + Some(token.token.clone()) + ) + .is_err()); + assert!( + require_risk_operation_token(command, "different-plan", Some(token.token.clone())) + .is_err() + ); + assert!(require_risk_operation_token(command, plan_id, Some(token.token.clone())).is_ok()); + assert!(require_risk_operation_token(command, plan_id, Some(token.token)).is_err()); + + let expired_plan = "expired-plan"; + let expired_token = create_confirmation_token( + Some(command.to_string()), + command.to_string(), + expired_plan.to_string(), + "high".to_string(), + risk_operation_fingerprint(command, expired_plan, "high"), + false, + None, + ) + .unwrap(); + { + let mut store = confirmation_tokens().lock().unwrap(); + let record = store.get_mut(&expired_token.token).unwrap(); + record.expires_at = unix_timestamp().saturating_sub(1); + } + assert!( + require_risk_operation_token(command, expired_plan, Some(expired_token.token)).is_err() + ); + } + + #[test] + fn risk_operation_registry_covers_required_commands() { + for command in [ + "apply_env_repair_plan", + "restore_user_environment", + "cleanup_path_entries", + "apply_user_environment_configuration", + "manage_system_platform", + "manage_local_service", + "stop_local_service", + "apply_project_configuration", + "update_project_port", + "rollback_move", + "execute_move_plan", + "execute_expansion_plan", + "clear_download_cache", + "clean_dev_cache", + "kill_process", + "execute_mysql_repair_plan", + ] { + let spec = risk_operation_spec(command).unwrap_or_else(|| panic!("missing {command}")); + assert!(spec.requires_token); + assert!(matches!(spec.risk_level, "medium" | "high" | "critical")); + } + } + #[test] fn desktop_process_not_classified_as_spring_by_port_only() { let signature = analyze_port_signature( diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index 810484e..410b0fc 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DevEnv Manager", - "version": "1.5.3", + "version": "1.6.0", "identifier": "com.weidonglang.dailytools", "build": { "beforeDevCommand": "npm run dev", diff --git a/tauri/src/components/confirmDialog.ts b/tauri/src/components/confirmDialog.ts index 20ed424..fb9c6e1 100644 --- a/tauri/src/components/confirmDialog.ts +++ b/tauri/src/components/confirmDialog.ts @@ -1,11 +1,96 @@ -export function confirmRisk(message: string, risk: string) { +type ConfirmOptions = { + title?: string; + confirmText?: string; + cancelText?: string; + danger?: boolean; + details?: string[]; + requiredText?: string; +}; + +function ensureConfirmHost() { + let host = document.querySelector("#confirm-host"); + if (host) return host; + host = document.createElement("div"); + host.id = "confirm-host"; + document.body.appendChild(host); + return host; +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (char) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char] || char; + }); +} + +export function askForConfirmation(message: string, options: ConfirmOptions = {}) { + const host = ensureConfirmHost(); + return new Promise((resolve) => { + const required = options.requiredText?.trim(); + host.innerHTML = ` + + `; + const cleanup = (answer: boolean) => { + host.innerHTML = ""; + resolve(answer); + }; + host.querySelector("#confirm-cancel")?.addEventListener("click", () => cleanup(false), { once: true }); + host.querySelector("#confirm-ok")?.addEventListener("click", () => { + const input = host.querySelector("#confirm-required-input"); + if (required && input?.value.trim() !== required) { + input?.classList.add("invalid"); + input?.focus(); + return; + } + cleanup(true); + }); + host.querySelector(".confirm-backdrop")?.addEventListener("click", (event) => { + if (event.target === event.currentTarget) cleanup(false); + }); + window.setTimeout(() => host.querySelector("#confirm-required-input")?.focus(), 0); + }); +} + +export async function confirmRisk(message: string, risk: string) { if (risk === "critical") { - if (!window.confirm(`${message}\n\n第一次确认:这是极高风险操作。`)) return false; - if (!window.confirm("第二次确认:我已经备份重要数据。")) return false; - return window.prompt("第三次确认:请输入 我已理解风险并确认执行") === "我已理解风险并确认执行"; + return askForConfirmation(message, { + title: "确认极高风险操作", + confirmText: "确认执行", + danger: true, + requiredText: "我已理解风险并确认执行", + details: [ + "该操作可能修改环境、进程、服务、文件或数据库状态。", + "请确认已经备份重要数据,并理解失败后的恢复方式。", + "前端确认只是交互提示,后端仍必须校验一次性 confirmation token。", + ], + }); } if (risk === "high" || risk === "medium") { - return window.confirm(`${message}\n\n该操作需要确认;请先确认已阅读风险说明。`); + return askForConfirmation(message, { + title: "确认受保护操作", + confirmText: "我已阅读风险说明", + danger: risk === "high", + details: ["请先核对计划预览、备份信息和影响范围。"], + }); } return true; } diff --git a/tauri/src/features/cleanup/index.ts b/tauri/src/features/cleanup/index.ts new file mode 100644 index 0000000..ff3dee7 --- /dev/null +++ b/tauri/src/features/cleanup/index.ts @@ -0,0 +1,3 @@ +export function fileDirectory(path: string, directory?: string) { + return directory || path.replace(/[\\/][^\\/]*$/, ""); +} diff --git a/tauri/src/features/jdk/index.ts b/tauri/src/features/jdk/index.ts new file mode 100644 index 0000000..71c6583 --- /dev/null +++ b/tauri/src/features/jdk/index.ts @@ -0,0 +1,3 @@ +export function projectConfigurationPlanId(projectPath: string, enabled: number, switchCount: number) { + return `${projectPath.trim().replace(/\//g, "\\").toLowerCase()}:${enabled}:${switchCount}`; +} diff --git a/tauri/src/features/mysql/index.ts b/tauri/src/features/mysql/index.ts new file mode 100644 index 0000000..a57ad6f --- /dev/null +++ b/tauri/src/features/mysql/index.ts @@ -0,0 +1,6 @@ +export const MYSQL_PERMISSION_UNKNOWN_HELP = + "当前权限不足以读取 Data 目录,因此无法判断系统库是否完整;这不等于 MySQL 已损坏。"; + +export function mysqlPathValue(label: string, value: string, clipboardIcon: string, escapeHtml: (value: string) => string) { + return `
${escapeHtml(label)}${escapeHtml(value || "未识别")}${value ? `` : ""}
`; +} diff --git a/tauri/src/features/ports/index.ts b/tauri/src/features/ports/index.ts new file mode 100644 index 0000000..82f56ca --- /dev/null +++ b/tauri/src/features/ports/index.ts @@ -0,0 +1,10 @@ +import type { PortRecord } from "../../types"; + +export function canShowKillPortAction(record: PortRecord) { + const name = (record.processName || "").toLowerCase(); + const identity = `${record.identity} ${record.riskLevel} ${record.risk}`.toLowerCase(); + if (!record.pid || record.pid <= 4) return false; + if (["system", "idle", "registry", "svchost.exe", "services.exe", "lsass.exe", "wininit.exe", "csrss.exe", "smss.exe"].includes(name)) return false; + if (identity.includes("system") || identity.includes("系统关键") || identity.includes("critical")) return false; + return true; +} diff --git a/tauri/src/features/safeMode/index.ts b/tauri/src/features/safeMode/index.ts new file mode 100644 index 0000000..c2e8a35 --- /dev/null +++ b/tauri/src/features/safeMode/index.ts @@ -0,0 +1,2 @@ +export const SAFE_MODE_DESCRIPTION = + "软件启动时遇到异常,已进入安全模式。安全模式只用于查看错误、重置界面配置和打开日志,不会自动扫描、修复、清理、安装、停止服务或写入环境变量。"; diff --git a/tauri/src/features/safety/index.ts b/tauri/src/features/safety/index.ts index 330d34c..823b408 100644 --- a/tauri/src/features/safety/index.ts +++ b/tauri/src/features/safety/index.ts @@ -1,3 +1,3 @@ -export { confirmRisk } from "../../components/confirmDialog"; +export { askForConfirmation, confirmRisk } from "../../components/confirmDialog"; export { disclaimerPanel } from "../../components/disclaimerPanel"; export { featureHelpCard } from "../../components/featureHelpCard"; diff --git a/tauri/src/features/toast/index.ts b/tauri/src/features/toast/index.ts new file mode 100644 index 0000000..0ba01cc --- /dev/null +++ b/tauri/src/features/toast/index.ts @@ -0,0 +1,65 @@ +type ToastOptions = { + sticky?: boolean; + durationMs?: number; + kind?: "info" | "success" | "warning" | "error"; +}; + +let toastTimer: number | null = null; + +function isPendingMessage(message: string) { + return /^(正在|请稍候|等待|准备|校验中|扫描中|分析中|下载中|运行中|刷新中)/.test(message.trim()) || message.includes("正在"); +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (char) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char] || char; + }); +} + +export function hideToast() { + if (toastTimer !== null) { + window.clearTimeout(toastTimer); + toastTimer = null; + } + const toast = document.querySelector("#toast"); + if (!toast) return; + toast.hidden = true; + toast.innerHTML = ""; +} + +export function showToast(message: string, isError = false, options: ToastOptions = {}) { + const toast = document.querySelector("#toast"); + if (!toast) return; + if (toastTimer !== null) { + window.clearTimeout(toastTimer); + toastTimer = null; + } + const kind = options.kind || (isError ? "error" : "info"); + const longError = isError && message.length > 180; + toast.innerHTML = ` +
+ ${ + longError + ? `
${escapeHtml(message.slice(0, 120))}...
${escapeHtml(message)}
` + : `${escapeHtml(message)}` + } +
+ + `; + toast.hidden = false; + toast.classList.toggle("error", isError); + toast.classList.toggle("warning", kind === "warning"); + const duration = options.sticky + ? 0 + : options.durationMs ?? (isError ? 0 : isPendingMessage(message) ? 0 : kind === "warning" ? 9000 : 5000); + if (duration > 0) { + toastTimer = window.setTimeout(() => hideToast(), duration); + } +} diff --git a/tauri/src/features/update/index.ts b/tauri/src/features/update/index.ts new file mode 100644 index 0000000..38681e9 --- /dev/null +++ b/tauri/src/features/update/index.ts @@ -0,0 +1,5 @@ +export function updateEmptyState(updateError: string, escapeHtml: (value: string) => string) { + return updateError + ? `
最近检查失败:${escapeHtml(updateError)}
` + : `
尚未检查新版本
`; +} diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 4215d69..2ed0f44 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -25,7 +25,14 @@ import { type IconNode, } from "lucide"; import { envReliabilityIntro } from "./envReliability"; -import { confirmRisk, disclaimerPanel, featureHelpCard } from "./features/safety"; +import { askForConfirmation, confirmRisk, disclaimerPanel } from "./features/safety"; +import { fileDirectory } from "./features/cleanup"; +import { projectConfigurationPlanId } from "./features/jdk"; +import { MYSQL_PERMISSION_UNKNOWN_HELP, mysqlPathValue } from "./features/mysql"; +import { canShowKillPortAction } from "./features/ports"; +import { SAFE_MODE_DESCRIPTION } from "./features/safeMode"; +import { hideToast, showToast } from "./features/toast"; +import { updateEmptyState } from "./features/update"; import { riskBadge } from "./components/riskBadge"; import type { AppSnapshot, @@ -145,7 +152,7 @@ app.innerHTML = ` 维护与系统 - + @@ -165,8 +172,8 @@ app.innerHTML = `
- ${icon(FileText)}这个页面怎么用? -

先看系统快照和当前生效工具;需要深入排查时再进入环境医生。

+ ${icon(FileText)}页面使用指南 +
先看系统快照和当前生效工具;需要深入排查时再进入环境医生。
@@ -202,17 +209,8 @@ app.innerHTML = `
-
${icon(Shield)}

迁移状态

-
    -
  • Tauri 2 桌面外壳
  • -
  • Rust 命令桥接
  • -
  • 端口扫描 MVP
  • -
  • JDK 下载、安装和切换
  • -
  • PATH 修复与恢复
  • -
  • Python / Node / Maven / Gradle 安装和验证
  • -
  • 环境医生、Python 冲突分析、项目启动向导
  • -
  • Git / Node / Python 开发工具链
  • -
+
${icon(RefreshCw)}

版本更新

+
尚未检查新版本
@@ -253,6 +251,7 @@ app.innerHTML = ` -- 还没有诊断结果 +
@@ -662,8 +661,8 @@ app.innerHTML = `
- Phase 3 · Analyze & rescue -

C 盘急救大师

+ 只读分析与安全清理 +

开发环境空间分析

严格执行扫描 → 选择 → 计划预览 → 二次确认 → 清理 → 验证 → 报告。普通文件优先移入 Windows 回收站。

@@ -671,7 +670,7 @@ app.innerHTML = `
-
${icon(FolderSearch)}

桌面急救

-
${icon(Shield)}只统计占用并生成整理建议;不删除、不移动桌面文件,归档计划将在 Phase 4 执行。
+
${icon(Shield)}只统计占用并生成整理建议;不删除、不移动桌面文件。归档整理必须先预览计划并再次确认。
尚未分析桌面
@@ -745,7 +742,7 @@ app.innerHTML = `
${icon(Boxes)}

空间搬家与归档

-
${icon(Shield)}Phase 4 支持桌面/下载归档、白名单目录搬家和 Junction 桥接;执行前必须预览计划并二次确认。
+
${icon(Shield)}支持桌面/下载归档、白名单目录搬家和 Junction 桥接;执行前必须预览计划并二次确认。
@@ -764,29 +761,18 @@ app.innerHTML = `
-
尚未生成空间搬家计划
+
还没有空间搬家计划
可从大文件或重复文件结果加入归档计划
${icon(RefreshCw)}

回滚记录

暂无可自动回滚记录
-
-
${icon(Activity)}

C 盘真扩容安全向导

-
${icon(Shield)}分区检测只读;真扩容仅在安全 A/B 模式下启用,并需要管理员权限、备份和三次确认。
-
尚未检测分区布局
-
- - +
+
+

报告

+

清理完成后显示释放空间、跳过项、失败项和可导出的 Markdown / JSON 报告。

+
还没有清理报告
-
尚未生成扩容计划
- ${[ - ["startup", "启动项/进程", "当前版本仅在总览统计启动目录项目数量。"], - ["report", "报告", "清理完成后会在这里显示结果。"], - ].map(([id, title, text]) => ` -
-

${title}

${text}

${id === "report" ? "等待清理结果" : "后续阶段开放"}${id === "report" ? `
尚无清理报告
` : ""}
-
- `).join("")}
@@ -797,18 +783,22 @@ app.innerHTML = `
尚未检查 Docker 与 WSL
-
- - - - - - -
-
- - -
+
+ ${icon(Shield)}高级 / 系统工具 +

这些入口可能安装、更新、退出系统级组件或触发 UAC;只在明确知道影响范围时使用。

+
+ + + + + + +
+
+ + +
+
Windows 主机与 WSL 是两套独立环境。本项目当前管理发行版状态;WSL 内的 SDK 优先交给 mise/asdf/sdkman/pyenv/nvm/rustup 等成熟工具。
@@ -875,22 +865,22 @@ app.innerHTML = ` -
尚未检查新版本
+
尚未检查新版本
-
-
${icon(Trash2)}

卸载本程序

+
+ ${icon(Trash2)}高级 / 卸载本程序
-
会打开 Windows 卸载器并关闭当前程序。
-
+
只打开 Windows 卸载器并关闭当前程序;不会自行删除用户项目、数据库或运行时目录。
+
${icon(FolderSearch)}

项目启动向导

- +
@@ -929,6 +919,7 @@ const state = { config: null as ConfigView | null, runtimes: [] as RuntimeInfo[], javaEnvironment: null as JavaEnvironmentReport | null, + externalJdkChecks: {} as Record, ports: [] as PortRecord[], portHistory: [] as PortHistorySummary[], selectedPort: null as PortRecord | null, @@ -978,6 +969,7 @@ const state = { appUsage: null as AppUsageReport | null, safeMode: false, fatalError: "", + safeModeNoticeCollapsed: false, }; const paginationState = new Map(); @@ -1197,17 +1189,24 @@ function renderJavaEnvironment() {

JDK 候选

- ${report.candidates.length ? report.candidates.map((candidate) => ` + ${report.candidates.length ? report.candidates.map((candidate) => { + const javaHome = candidate.executable.replace(/\\bin\\java\.exe$/i, ""); + const checks = state.externalJdkChecks[javaHome] || []; + return `
${escapeHtml(candidate.version.split("\n")[0] || "Java")}${escapeHtml(candidate.source)}
${escapeHtml(candidate.executable)} + ${checks.length ? `
${renderValidationChecks(checks)}
` : ""}
- + + +
- `).join("") : `
没有发现 JDK 候选
`} + `; + }).join("") : `
没有发现 JDK 候选
`}
  • 外部、IDE 内置、Scoop、Chocolatey、mise、asdf JDK 不会被 DevEnv Manager 卸载、删除或移入回收站。
  • @@ -1378,7 +1377,7 @@ function renderPorts() { ${record.localPort}${escapeHtml(record.protocol)} · ${escapeHtml(record.localAddress)} ${escapeHtml(record.state)} - ${escapeHtml(record.identity || record.commonUsage || "Unknown")}${conflict ? `${conflict}冲突` : ""} + ${escapeHtml(record.identity || record.commonUsage || "Unknown")}${conflict ? `` : ""} ${escapeHtml(record.processName || "未读取")} ${record.pid} = 40 ? "warn" : "muted"}">${confidenceLabel(record.confidence)}${record.evidenceCount || record.evidence?.length || 0}证据 @@ -1449,7 +1448,7 @@ function renderPortDetails() { ${isHttpLike ? `` : ""} ${isDatabase ? `` : ""} - + ${canShowKillPortAction(record) ? `` : `系统关键或高风险进程不提供结束入口`} `; } @@ -1895,6 +1894,21 @@ async function inspectPlatforms(message = "正在检查平台工具链") { } } +async function checkUpdates() { + showToast("正在检查新版本"); + try { + state.update = await invoke("check_for_updates"); + state.updateError = ""; + window.localStorage.setItem("devenv-last-update-check", String(Date.now())); + renderUpdate(); + showToast(state.update.updateAvailable ? `发现新版本 ${state.update.latestVersion}` : "当前已是最新版本"); + } catch (error) { + state.updateError = error instanceof Error ? error.message : String(error); + renderUpdate(); + showToast(state.updateError, true); + } +} + async function runPlatformAction(action: string, value: string | null = null) { showToast("正在执行平台工具链操作"); try { @@ -2256,6 +2270,7 @@ async function refreshBase() { renderDuplicates(); renderAppUsage(); renderPorts(); + renderViewGuide(); renderFeatureHelp(); const autoCheckUpdates = document.querySelector("#auto-check-updates"); if (autoCheckUpdates) autoCheckUpdates.checked = config.settings.autoCheckUpdate; @@ -2335,20 +2350,20 @@ async function runRuntimeOperation( async function terminatePortProcess(pid: number) { const record = state.ports.find((item) => item.pid === pid); const label = record ? `${record.processName} / PID ${pid}` : `PID ${pid}`; - if (!window.confirm(`将结束 ${label} 及其子进程。确定继续吗?`)) return; + if (!(await askForConfirmation(`将结束 ${label} 及其子进程。确定继续吗?`))) return; try { const planId = `pid-${pid}-force-false-allow-false`; const fingerprint = await processActionFingerprint("kill_process", planId, "high"); const token = await createBackendConfirmation("kill_process", planId, "high", fingerprint, false); let result = await invoke("kill_process", { pid, force: false, allowCaution: false, confirmationToken: token.token }); if (result.needsForce) { - const force = window.confirm(`${result.message}\n\n是否改为强制结束?`); + const force = await askForConfirmation(`${result.message}\n\n是否改为强制结束?`); if (!force) { showToast("已取消强制结束"); return; } - if (!window.confirm("强制结束是极高风险操作。第一次确认:我已保存相关工作。")) return; - if (!window.confirm("第二次确认:我理解这可能导致数据未保存或服务中断。")) return; + if (!(await askForConfirmation("强制结束是极高风险操作。第一次确认:我已保存相关工作。"))) return; + if (!(await askForConfirmation("第二次确认:我理解这可能导致数据未保存或服务中断。"))) return; const forcePlanId = `pid-${pid}-force-true-allow-false`; const forceFingerprint = await processActionFingerprint("kill_process", forcePlanId, "critical"); const forceToken = await createBackendConfirmation("kill_process", forcePlanId, "critical", forceFingerprint, true); @@ -2375,7 +2390,10 @@ async function copyText(text: string) { async function runDoctorAction(action: string) { if (action === "cleanup_path") { - await runOperation(() => invoke("cleanup_path_entries"), "正在清理 PATH"); + await runOperation(async () => { + const token = await riskOperationToken("cleanup_path_entries", "cleanup-path-entries", "medium", false, "environment-backup"); + return invoke("cleanup_path_entries", { confirmationToken: token.token }); + }, "正在清理 PATH"); return; } if (action === "configure_env") { @@ -2444,14 +2462,6 @@ async function runDoctorAction(action: string) { } } -function showToast(message: string, isError = false) { - const toast = document.querySelector("#toast"); - if (!toast) return; - toast.textContent = message; - toast.hidden = false; - toast.classList.toggle("error", isError); -} - function errorToText(error: unknown) { if (error instanceof Error) return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ""}`; return String(error); @@ -2494,7 +2504,7 @@ function renderSafetyGate() { function renderFatalError() { const element = document.querySelector("#fatal-error"); if (!element) return; - if (!state.safeMode && !state.fatalError) { + if ((!state.safeMode && !state.fatalError) || state.safeModeNoticeCollapsed) { element.hidden = true; element.innerHTML = ""; return; @@ -2502,9 +2512,12 @@ function renderFatalError() { element.hidden = false; element.innerHTML = `
    -
    -

    已进入安全模式

    -

    初始化或运行时出现错误。安全模式不会自动扫描、修复、清理、安装、停止服务或写入环境变量。

    +
    +
    +

    已进入安全模式

    +

    ${escapeHtml(SAFE_MODE_DESCRIPTION)}

    +
    +
    ${escapeHtml(state.fatalError || "未知错误")}
    @@ -2520,8 +2533,8 @@ function renderFatalError() { function enterSafeMode(error: unknown, context = "运行时错误") { state.safeMode = true; state.fatalError = `${context}\n${errorToText(error)}`; + state.safeModeNoticeCollapsed = false; renderFatalError(); - showToast("已进入安全模式", true); } function renderProgress(progress: TaskProgress) { @@ -2609,9 +2622,10 @@ function renderMySqlRepair() {
    ${report.candidates.length ? report.candidates.map((candidate) => `
    ${escapeHtml(candidate.serviceName)} · MySQL ${escapeHtml(candidate.versionHint)}${escapeHtml(candidate.conclusionLevel)} / ${escapeHtml(candidate.serviceState)}
    + ${candidate.conclusionLevel === "PermissionUnknown" ? `
    ${icon(Shield)}${escapeHtml(MYSQL_PERMISSION_UNKNOWN_HELP)}
    ` : ""}

    概览

    服务名${escapeHtml(candidate.serviceName)}
    服务状态${escapeHtml(candidate.serviceState)}
    端口${candidate.port} · ${candidate.portOccupied ? "已占用" : "空闲"}
    端口占用进程${escapeHtml(candidate.portProcess)}
    结论可信度${escapeHtml(candidate.confidence)}
    -

    证据

    mysqld${escapeHtml(candidate.mysqldPath)}
    my.ini${escapeHtml(candidate.myIniPath)}
    basedir${escapeHtml(candidate.basedir)}
    datadir${escapeHtml(candidate.datadir)}
    静态文件检查${escapeHtml(candidate.staticFileCheck)}
    连接验证${escapeHtml(candidate.connectionCheck)}
    系统 schema${escapeHtml(candidate.systemSchemaCheck)}
    业务库候选${escapeHtml(candidate.businessDatabases.join("、") || "未发现")}
    +

    证据

    ${mysqlPathValue("mysqld", candidate.mysqldPath, icon(Clipboard), escapeHtml)}${mysqlPathValue("my.ini", candidate.myIniPath, icon(Clipboard), escapeHtml)}${mysqlPathValue("basedir", candidate.basedir, icon(Clipboard), escapeHtml)}${mysqlPathValue("datadir", candidate.datadir, icon(Clipboard), escapeHtml)}
    静态文件检查${escapeHtml(candidate.staticFileCheck)}
    连接验证${escapeHtml(candidate.connectionCheck)}
    系统 schema${escapeHtml(candidate.systemSchemaCheck)}
    业务库候选${escapeHtml(candidate.businessDatabases.join("、") || "未发现")}

    风险

      ${candidate.reasoning.map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}${candidate.suggestions.map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}

    备份

    ${renderMySqlBackupManifest(candidate.backupManifest)}
    @@ -2694,16 +2708,12 @@ function renderCache() { } function renderUpdate() { - const element = document.querySelector("#update-result"); + const elements = Array.from(document.querySelectorAll("[data-update-result]")); const update = state.update; - if (!element) return; - if (!update) { - element.innerHTML = state.updateError - ? `
    最近检查失败:${escapeHtml(state.updateError)}
    ` - : `
    尚未检查新版本
    `; - return; - } - element.innerHTML = ` + if (!elements.length) return; + const html = !update + ? updateEmptyState(state.updateError, escapeHtml) + : `
    当前 ${escapeHtml(update.currentVersion)} · 最新 ${escapeHtml(update.latestVersion)} ${update.updateAvailable ? "发现新版本" : "当前已是最新版本"} · 发布 ${escapeHtml(update.date)} · 检查 ${escapeHtml(update.checkedAt)} @@ -2715,6 +2725,9 @@ function renderUpdate() {
    `; + elements.forEach((element) => { + element.innerHTML = html; + }); } function riskText(risk: string) { @@ -2855,8 +2868,10 @@ async function createBackendConfirmation( planFingerprint: string, tripleConfirmed: boolean, backupReceipt?: string | null, + command?: string, ) { return invoke("create_confirmation_token", { + command: command || actionId, actionId, planId, riskLevel, @@ -2870,6 +2885,18 @@ async function processActionFingerprint(actionId: string, planId: string, riskLe return sha256Hex(`${actionId}\0${planId}\0${riskLevel}`); } +async function riskOperationToken( + command: string, + planId: string, + riskLevel: "medium" | "high" | "critical", + tripleConfirmed = false, + backupReceipt: string | null = null, + actionId = command, +) { + const fingerprint = await sha256Hex(`${command}\0${planId}\0${riskLevel}`); + return createBackendConfirmation(actionId, planId, riskLevel, fingerprint, tripleConfirmed, backupReceipt, command); +} + function renderSafetyDisclaimer() { const slot = document.querySelector("#safety-disclaimer-slot"); if (!slot) return; @@ -2975,16 +3002,71 @@ function renderCleanupResult() { function renderFolderUsage(target: string, report: FolderUsageReport | null, key: string) { const element = document.querySelector(target); if (!element || !report) return; - element.innerHTML = `
    ${escapeHtml(report.name)} · ${formatBytes(report.totalBytes)}${escapeHtml(report.path)}
    -
    ${paginate(key, report.categories, (category) => `
    ${escapeHtml(category.name)}${formatBytes(category.size)}
    ${escapeHtml(category.suggestion)}
    `)}
    -
      ${[...report.suggestions, ...report.warnings].map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}
    `; + const rescanTarget = key.includes("desktop") ? "desktop" : "downloads"; + const categoryKey = `${key}-categories`; + const topFileKey = `${key}-top-files`; + element.innerHTML = ` +
    +
    + ${escapeHtml(report.name)} + ${formatBytes(report.totalBytes)} +
    + ${escapeHtml(report.path)} +
    ${[...report.suggestions, ...report.warnings].map((item) => `${escapeHtml(item)}`).join("")}
    +
    +
    +
    +

    分类占用

    按文件类型、时间和用途分组;展开分类可查看该类 Top 文件。
    + ${report.categories.length} 类 +
    +
    ${paginate(categoryKey, report.categories, (category) => `
    ${escapeHtml(category.name)}${formatBytes(category.size)}
    ${escapeHtml(category.suggestion)}
    ${category.details.length ? paginate(`${categoryKey}-${category.name}`, category.details, (item) => renderFileDetail(item, rescanTarget), 4) : `
    这个分类下没有可展示的 Top 文件明细
    `}
    `, 4)}
    +
    +
    +
    +

    Top 文件明细

    优先展示最占空间的具体文件;每页最多 6 项,便于逐个定位和复制路径。
    + +
    +
    ${report.topFiles.length ? paginate(topFileKey, report.topFiles, (item) => renderFileDetail(item, rescanTarget), 6) : `
    没有可展示的文件明细
    `}
    +
    + `; +} + +function renderFileDetail(item: LargeFileItem, rescanTarget: string) { + const directory = fileDirectory(item.path, item.directory); + const modified = item.modifiedAt || "未知修改时间"; + const extension = item.extension || "无扩展名"; + const locateLabel = item.exists ? "选中文件" : "尝试定位"; + return `
    +
    + ${escapeHtml(item.fileName || item.path)} + ${formatBytes(item.size)} +
    +
    + ${escapeHtml(extension)} + ${escapeHtml(item.fileType)} + ${escapeHtml(item.sourceCategory)} + ${escapeHtml(modified)} + ${item.exists ? "仍存在" : "已移动或删除"} + ${item.canLocate ? "可定位" : "不可定位"} +
    + 完整路径:${escapeHtml(item.path)} + 所在目录:${escapeHtml(directory)} + ${escapeHtml(item.openStatus || item.suggestion)} +
    + + + + + +
    +
    `; } function renderLargeFiles() { const element = document.querySelector("#large-file-result"); if (!element) return; element.innerHTML = state.largeFiles.length - ? paginate("large-files", state.largeFiles, (item) => `
    ${escapeHtml(item.fileType)} · ${formatBytes(item.size)}${riskText(item.risk)}风险
    ${escapeHtml(item.path)}${escapeHtml(item.suggestion)}
    `, 10) + ? paginate("large-files", state.largeFiles, (item) => `${renderFileDetail(item, "large-files")}
    `, 10) : `
    扫描范围内没有达到阈值的大文件
    `; } @@ -2993,7 +3075,7 @@ function renderArchivePlan() { if (!element) return; element.innerHTML = state.archivePlan.length ? paginate("archive-plan", state.archivePlan, (item) => `
    ${escapeHtml(item.source)} · ${formatBytes(item.size)}仅计划
    ${escapeHtml(item.path)}${escapeHtml(item.suggestion)}
    `, 10) - : `
    归档计划为空;Phase 4 执行前仍需先生成搬家或归档计划
    `; + : `
    归档计划为空;先从大文件/重复文件结果加入候选,或生成搬家/归档计划
    `; } async function loadArchivePlan() { @@ -3023,7 +3105,7 @@ function renderMovePlan() { ${result.failures.length ? `
      ${result.failures.map((failure) => `
    • ${escapeHtml(failure)}
    • `).join("")}
    ` : "无失败项"}
    ${escapeHtml(result.reportMarkdown)}
    ` : ""}` - : `
    尚未生成空间搬家计划
    `; + : `
    还没有空间搬家计划
    `; } function renderRollbackRecords() { @@ -3081,7 +3163,7 @@ function renderExpansionPlan() {
      ${plan.risks.map((risk) => `
    • ${escapeHtml(risk)}
    • `).join("")}
    ${result ? `
    ${result.success ? "扩容成功" : "扩容未成功"}${formatBytes(result.beforeTotal)} → ${formatBytes(result.afterTotal)}
    ${escapeHtml(result.reportMarkdown)}
    ` : ""}` - : `
    尚未生成扩容计划
    `; + : `
    还没有扩容计划
    `; } function renderDuplicates() { @@ -3150,21 +3232,7 @@ function activateView(view: string) { document.querySelectorAll(".view").forEach((item) => { item.classList.toggle("active", item.id === `view-${view}`); }); - const guides: Record = { - overview: "先确认当前实际生效的工具版本与路径;这里只读刷新,不会修改环境。", - doctor: "先运行一键诊断,再逐条查看证据。安全修复只处理用户级 PATH 与受管环境变量。", - ports: "搜索端口、进程或框架名称;结束进程前务必确认它不是系统或仍在使用的服务。", - runtimes: "本机发现默认折叠。JDK 切换后请用“检查当前 JDK”核对 JAVA_HOME、PATH、java 和 javac。", - environment: "配置操作只写当前用户环境变量,并保留快照;新终端或 IDE 才会继承修改。", - project: "选择项目根目录后只读分析配置;运行按钮只接受后端生成的固定 action id。", - toolchains: "优先检测并调用 Git、npm、pnpm、uv 等成熟工具,不替代它们。", - platforms: "用于诊断 Go、Rust、.NET 与镜像配置;写配置前会备份或明确确认。", - learning: "只运行固定的版本、位置与环境检查命令;不会安装工具或修改配置。", - maintenance: "Phase 2 清理必须经过扫描、一次性计划和二次确认;Phase 3 个人目录与应用分析保持只读。", - toolbox: "命令面板是高级功能且启用白名单;不要粘贴不理解的 AI 或网页命令。", - }; - const guide = document.querySelector("#view-guide-text"); - if (guide) guide.textContent = guides[view] || guides.overview; + renderViewGuide(view); renderFeatureHelp(view); } @@ -3207,53 +3275,142 @@ const VIEW_FEATURE_MAP: Record = { toolbox: "command-panel", }; -function renderFeatureHelp(view = document.querySelector(".nav-item.active")?.getAttribute("data-view") || "overview") { - const slot = document.querySelector("#feature-help-slot"); - if (!slot) return; +function currentView() { + return document.querySelector(".nav-item.active")?.getAttribute("data-view") || "overview"; +} + +type GuideDefinition = { + title: string; + intro: string; + steps: string[]; + readonly: string[]; + writes: string[]; + safety: string[]; +}; + +const VIEW_GUIDES: Record = { + overview: { + title: "总览", + intro: "总览用于快速判断这台 Windows 开发机当前是否健康:它聚合当前生效运行时、PATH 风险、端口数量、版本更新状态和关键提醒。适合刚打开软件时先看一眼,再决定去环境医生、端口、项目或空间分析页面继续排查。", + steps: ["先点“刷新”读取最新快照。", "如果看到 PATH、JAVA_HOME、端口或更新异常,再跳转到对应页面。", "需要给别人描述问题时,优先复制总览和环境医生报告。"], + readonly: ["读取当前配置、受管运行时清单、版本更新清单和本机状态。", "不会安装软件、修改环境变量、停止进程或清理文件。"], + writes: ["总览页本身没有写入动作;更新下载/安装会跳到工具箱更新流程处理。"], + safety: ["如果检查失败,不影响其它页面继续使用。", "更新检查失败通常是网络或 GitHub 访问问题,可以复制错误后稍后重试。"], + }, + doctor: { + title: "环境医生", + intro: "环境医生把 PATH、JAVA_HOME、Python/pip、端口、缓存和常见配置问题整理成可解释的证据。适合“不知道哪里坏了”的场景,用它先定位原因,再决定是否执行安全修复。", + steps: ["先点“一键诊断”。", "逐条查看警告和建议,优先处理影响当前项目启动的问题。", "执行修复前先看页面里的 diff、备份名和风险等级。"], + readonly: ["诊断、导出报告、复制建议和网络/端口检查是只读。", "报告会脱敏本机用户名、令牌和敏感路径片段。"], + writes: ["安全修复可能写入当前用户 PATH 或受管环境变量。", "PATH 清理会移除重复、失效或旧 DevEnv 受管残留项。"], + safety: ["修改类动作需要后端 confirmation token,并在执行前建立可恢复记录。", "失败后先重新诊断,再从备份列表恢复,不要反复点击同一个修复按钮。"], + }, + ports: { + title: "端口管理", + intro: "端口管理用于识别本机监听端口、进程身份、冲突证据、父进程、Windows 服务和历史记录。适合排查 8080、3306、5173、6379 等端口被占用或误判的问题。", + steps: ["先点“扫描”。", "点击冲突徽标或详情按钮查看证据来源。", "用搜索、排序和快捷筛选缩小到数据库、Web、桌面应用或未知进程。"], + readonly: ["扫描、详情、复制 curl/连接命令、打开进程位置都是只读。", "端口身份不会只凭端口号下结论,会结合进程名、路径、命令行、服务名和冲突证据。"], + writes: ["安全结束进程、停止服务会改变运行状态。", "系统关键进程、PID 过低或高风险进程不会显示结束入口。"], + safety: ["结束进程和停止服务都需要后端 token。", "如果端口属于数据库或系统服务,优先用服务管理入口停止,不建议直接杀进程。"], + }, + runtimes: { + title: "运行时", + intro: "运行时页面用于发现、验证和切换 JDK、Node.js、Python、Maven、Gradle、Go 等开发工具。它区分 DevEnv 受管版本、系统安装版本、IDE 自带版本和外部手动路径。", + steps: ["先点“发现版本”刷新受管和外部候选。", "JDK 问题优先点“检查当前 JDK”或对外部 JDK 做只读验证。", "确认 java/javac/jar 都可用后,再生成 JAVA_HOME 稳定计划。"], + readonly: ["发现版本、外部 JDK 验证、java/javac/jar 检查是只读。", "外部 JDK 不会被卸载、移动或接管。"], + writes: ["切换受管运行时会更新 current 指针,并可能配合环境页写入用户环境变量。", "安装受管版本会下载到 DevEnv 管理目录。"], + safety: ["写入环境变量前会先生成计划和备份。", "IDE 捆绑运行时默认只展示和验证,不作为卸载目标。"], + }, + environment: { + title: "环境变量", + intro: "环境变量页面专门检查用户级 JAVA_HOME、DEVENV_HOME、PATH 顺序、java/javac/pip 命中和 Maven/Gradle 使用的 Java。适合处理终端里版本和软件界面看到的不一致。", + steps: ["先点“检查可靠性”。", "确认冲突来源后再生成修复计划。", "应用计划前核对 diff、备份名和是否需要重启终端。"], + readonly: ["可靠性检查、修复计划预览、备份列表查看、报告导出是只读。", "页面会展示 raw 值、展开后的路径和命令验证结果。"], + writes: ["应用计划会写当前用户环境变量。", "恢复环境会用最近备份替换用户级配置。"], + safety: ["应用、恢复和 PATH 清理都需要后端 token。", "如果预览后环境变量发生变化,后端会拒绝写入并要求重新预览。"], + }, + project: { + title: "项目", + intro: "项目页用于识别项目类型、读取 IDE 配置、验证 Java 消费者、生成 VS Code/IDEA 配置预览,以及备份后修改项目端口。适合启动项目、接手项目或排查端口冲突前使用。", + steps: ["先选择项目目录;默认不会自动填本机路径。", "点击“分析”识别项目类型和运行建议。", "需要写配置或改端口时,先生成预览并核对文件内容。"], + readonly: ["项目分析、IDEA 配置读取、Nacos/Nexus Java 验证和端口配置扫描是只读。", "页面只读取常见安全配置文件,不深扫源码内容。"], + writes: ["应用项目配置会写 VS Code/IDEA 等项目配置文件。", "修改端口会备份原文件并只替换识别到的端口项。"], + safety: ["写项目文件和端口修改都需要 token。", "后端会限制写入路径,避免越界修改项目外文件。"], + }, + toolchains: { + title: "工具链", + intro: "工具链页面向 Git、SSH、Node 包管理器、Python/pip 和常用 CLI 配置。适合首次配置开发机、修复 pip/npm 源或确认命令是否可用。", + steps: ["先点“全面检查”。", "Git 身份、SSH key、pip/npm 源按页面提示逐项处理。", "执行前阅读会写入哪些用户配置文件。"], + readonly: ["检查命令版本、读取配置状态、复制建议命令是只读。", "命令输出会做基础脱敏。"], + writes: ["保存 Git 身份、生成 SSH key、切换 pip/npm/chsrc 源会写用户配置。", "受管 pip 修复会先生成计划再执行。"], + safety: ["写配置前会提示影响范围,必要时生成备份。", "失败后优先复制错误和命令输出,不要手动删除配置目录。"], + }, + platforms: { + title: "平台与镜像", + intro: "平台页用于检查 Go、Rust、.NET、chsrc 和镜像源配置。它更像开发平台体检,不替代各生态成熟包管理器。", + steps: ["先点“全面检查”。", "根据生态选择 Go/Rust/.NET 或 chsrc 操作。", "切换镜像前确认团队或项目是否有固定要求。"], + readonly: ["版本检查、镜像测速和配置读取是只读。", "不会自动安装或卸载生态运行时。"], + writes: ["镜像切换会写对应工具的用户配置文件。", "chsrc 操作会调用受控白名单命令。"], + safety: ["写入前会备份或提示可恢复路径。", "如果公司网络有代理/内网源,先复制现有配置再改。"], + }, + learning: { + title: "学习中心", + intro: "学习中心只运行固定白名单里的只读检查命令,帮助你理解 where、version、doctor 等命令输出。适合学习排查思路,而不是执行修复。", + steps: ["选择预置命令或输入只读命令。", "运行后看 stdout/stderr 和安全评估。", "把输出带到其它页面决定下一步。"], + readonly: ["允许的命令只用于查看版本、路径和诊断信息。", "被拒绝的命令会说明原因。"], + writes: ["学习中心不安装工具、不改配置、不清理文件、不结束进程。"], + safety: ["PowerShell/cmd、破坏性 Git、磁盘/注册表/权限类命令会被拦截。", "不要粘贴看不懂的网页命令。"], + }, + maintenance: { + title: "空间分析", + intro: "空间分析用于做 C 盘只读体检、扫描低风险缓存、查看桌面/下载目录大文件、重复文件、常见应用占用,并在确认后生成清理或归档计划。适合先找证据,再少量、安全地释放空间。", + steps: ["先点“开始体检”看总体风险。", "桌面急救和下载目录先用“只读分析”,分页查看分类占用和 Top 文件。", "只把确认不需要的项目加入计划,再预览清理或归档。"], + readonly: ["体检、扫描、文件定位、复制路径、重复候选分析和应用占用统计都是只读。", "桌面/下载明细只展示文件名、路径、目录、大小、修改时间、类型和定位状态。"], + writes: ["清理会重新校验选中项,普通文件进入回收站或调用官方缓存命令。", "归档/搬家会先生成计划,展示源、目标、估算大小、风险和回滚信息。"], + safety: ["不会自动删除未选择文件,不会读取数据库正文或浏览器凭据。", "执行清理、搬家、回滚和扩容计划都需要 token;失败后先重新扫描。"], + }, + toolbox: { + title: "工具箱", + intro: "工具箱承载命令面板、更新、本地服务、Docker/WSL 和卸载等高级入口。它适合有明确目标时使用,不建议把这里当作一键系统管家。", + steps: ["先展开对应高级区并阅读说明。", "服务和 Docker/WSL 操作前确认目标名称、端口和当前状态。", "更新前先检查版本,再下载并校验安装包。"], + readonly: ["服务检查、日志读取、Docker/WSL 状态检查、更新检查是只读。", "打开系统位置或复制日志不会修改状态。"], + writes: ["启动/停止服务、Docker/WSL 安装更新、下载更新、自卸载会改变系统状态或打开系统工具。", "自卸载只打开 Windows 卸载器并关闭程序,不主动删除项目、数据库或运行时目录。"], + safety: ["系统级动作折叠在高级区,并要求 token 或明确确认。", "失败后不要连续重复点击,先复制日志或错误信息。"], + }, +}; + +function riskInfoForView(view: string) { const featureId = VIEW_FEATURE_MAP[view] || "overview"; - const info = state.featureRisks.find((item) => item.featureId === featureId); - if (!info) { - slot.innerHTML = ""; - return; - } - const card = featureHelpCard( - escapeHtml(info.title), - escapeHtml(info.riskLevel), - [ - ...info.whatItDoes, - `确认级别:${info.confirmationLevel === "none" ? "无需确认" : info.confirmationLevel === "triple" ? "三次确认" : "二次确认"}`, - info.requiresBackup ? "执行前需要备份或生成可恢复记录。" : "只读或低风险操作通常不需要备份。", - ].map(escapeHtml), - [ - ...info.whatItDoesNotDo, - info.requiresAdmin ? "需要管理员权限时会明确提示。" : "不会静默请求管理员权限。", - ].map(escapeHtml), - ); - const collapsed = featureHelpCollapsed(view); - slot.innerHTML = collapsed - ? `` - : `
    -
    - -
    - ${card} -
    `; + return state.featureRisks.find((item) => item.featureId === featureId); } -function featureHelpCollapsed(view: string) { - try { - return window.localStorage.getItem(`devenv.featureHelp.collapsed.${view}`) === "true"; - } catch { - return false; - } +function renderViewGuide(view = currentView()) { + const guide = document.querySelector("#view-guide-text"); + if (!guide) return; + const definition = VIEW_GUIDES[view] || VIEW_GUIDES.overview; + const info = riskInfoForView(view); + const riskHtml = info + ? `

    风险与边界

      +
    • 风险等级:${escapeHtml(info.riskLevel)};确认级别:${escapeHtml(info.confirmationLevel === "none" ? "无需确认" : info.confirmationLevel === "triple" ? "三次确认" : "二次确认")}。
    • +
    • ${info.requiresBackup ? "执行前需要备份或生成可恢复记录。" : "主要是只读或低风险动作,通常不需要备份。"}
    • + ${info.whatItDoes.map((item) => `
    • 能做:${escapeHtml(item)}
    • `).join("")} + ${info.whatItDoesNotDo.map((item) => `
    • 不会做:${escapeHtml(item)}
    • `).join("")} +
    ` + : ""; + guide.innerHTML = ` +

    ${escapeHtml(definition.title)}

    ${escapeHtml(definition.intro)}

    +

    建议流程

      ${definition.steps.map((item) => `
    1. ${escapeHtml(item)}
    2. `).join("")}
    +

    只读能力

      ${definition.readonly.map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}
    +

    会修改什么

      ${definition.writes.map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}
    +

    安全与失败处理

      ${definition.safety.map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}
    + ${riskHtml} + `; } -function setFeatureHelpCollapsed(view: string, collapsed: boolean) { - try { - window.localStorage.setItem(`devenv.featureHelp.collapsed.${view}`, collapsed ? "true" : "false"); - } catch { - // localStorage can be unavailable in recovery contexts; default-expanded remains safe. - } +function renderFeatureHelp(_view = currentView()) { + const slot = document.querySelector("#feature-help-slot"); + if (!slot) return; + slot.innerHTML = ""; } function escapeHtml(value: string) { @@ -3468,31 +3625,31 @@ document.querySelector("#set-go-proxy")?.addEventListener("click", () => { document.querySelector("#rust-stable")?.addEventListener("click", () => { void runPlatformAction("rust_default_stable"); }); -document.querySelector("#rust-update")?.addEventListener("click", () => { - if (!window.confirm("rustup 将联网更新当前用户安装的 Rust 工具链,可能需要一些时间。确定继续吗?")) return; +document.querySelector("#rust-update")?.addEventListener("click", async () => { + if (!(await askForConfirmation("rustup 将联网更新当前用户安装的 Rust 工具链,可能需要一些时间。确定继续吗?"))) return; void runPlatformAction("rust_update"); }); document.querySelector("#copy-cargo-mirror")?.addEventListener("click", () => { void copyText(`[source.crates-io]\nreplace-with = "rsproxy-sparse"\n\n[source.rsproxy-sparse]\nregistry = "sparse+https://rsproxy.cn/index/"`); }); -document.querySelector("#set-maven-mirror")?.addEventListener("click", () => { +document.querySelector("#set-maven-mirror")?.addEventListener("click", async () => { const value = document.querySelector("#maven-mirror")?.value || "official"; const path = state.platforms?.mirrors.mavenSettingsPath || "%USERPROFILE%\\.m2\\settings.xml"; - if (!window.confirm(`将写入 ${path}。若文件已存在,会先创建带时间戳的备份。确定继续吗?`)) return; + if (!(await askForConfirmation(`将写入 ${path}。若文件已存在,会先创建带时间戳的备份。确定继续吗?`))) return; void runPlatformAction("maven_mirror", value); }); -document.querySelector("#set-gradle-mirror")?.addEventListener("click", () => { +document.querySelector("#set-gradle-mirror")?.addEventListener("click", async () => { const value = document.querySelector("#gradle-mirror")?.value || "official"; const path = state.platforms?.mirrors.gradleInitPath || "%USERPROFILE%\\.gradle\\init.gradle"; - if (!window.confirm(`将写入 ${path}。若文件已存在,会先创建带时间戳的备份。确定继续吗?`)) return; + if (!(await askForConfirmation(`将写入 ${path}。若文件已存在,会先创建带时间戳的备份。确定继续吗?`))) return; void runPlatformAction("gradle_mirror", value); }); -document.querySelector("#restore-maven-config")?.addEventListener("click", () => { - if (!window.confirm("将恢复最近一次 DevEnv Manager 备份的 Maven 配置,并保留当前配置备份。确定继续吗?")) return; +document.querySelector("#restore-maven-config")?.addEventListener("click", async () => { + if (!(await askForConfirmation("将恢复最近一次 DevEnv Manager 备份的 Maven 配置,并保留当前配置备份。确定继续吗?"))) return; void runPlatformAction("restore_maven_config"); }); -document.querySelector("#restore-gradle-config")?.addEventListener("click", () => { - if (!window.confirm("将恢复最近一次 DevEnv Manager 备份的 Gradle 配置,并保留当前配置备份。确定继续吗?")) return; +document.querySelector("#restore-gradle-config")?.addEventListener("click", async () => { + if (!(await askForConfirmation("将恢复最近一次 DevEnv Manager 备份的 Gradle 配置,并保留当前配置备份。确定继续吗?"))) return; void runPlatformAction("restore_gradle_config"); }); document.querySelector("#open-package-mirrors")?.addEventListener("click", () => { @@ -3508,13 +3665,13 @@ document.querySelector("#save-git-identity")?.addEventListener("click", () => { } void runToolchainAction("git_identity", name, email); }); -document.querySelector("#generate-ssh-key")?.addEventListener("click", () => { +document.querySelector("#generate-ssh-key")?.addEventListener("click", async () => { const email = document.querySelector("#git-user-email")?.value.trim() || ""; if (!email) { showToast("请先填写用于 SSH Key 注释的邮箱", true); return; } - if (!window.confirm("将在当前用户 .ssh 目录生成 id_ed25519。已有同名密钥时会自动拒绝覆盖,确定继续吗?")) return; + if (!(await askForConfirmation("将在当前用户 .ssh 目录生成 id_ed25519。已有同名密钥时会自动拒绝覆盖,确定继续吗?"))) return; void runToolchainAction("git_generate_ssh", email); }); document.querySelector("#test-github-ssh")?.addEventListener("click", () => void runToolchainAction("git_test_ssh")); @@ -3574,10 +3731,11 @@ document.querySelector("#create-java-stabilize-plan")?.addEventListener("click", document.querySelector("#apply-env-repair-plan")?.addEventListener("click", async () => { const plan = state.envRepairPlan; if (!plan) return; - if (!confirmRisk(`将写入当前用户级环境变量,并创建备份:${plan.backupName}`, plan.riskLevel)) return; + if (!(await confirmRisk(`将写入当前用户级环境变量,并创建备份:${plan.backupName}`, plan.riskLevel))) return; showToast("正在应用环境修复计划并验证"); try { - state.envRepairResult = await invoke("apply_env_repair_plan", { plan }); + const token = await riskOperationToken("apply_env_repair_plan", plan.planId, "high", false, plan.backupName); + state.envRepairResult = await invoke("apply_env_repair_plan", { plan, confirmationToken: token.token }); state.envRepairPlan = null; state.envReliability = await invoke("inspect_env_reliability"); state.envBackupRecords = await invoke("list_env_backups"); @@ -3625,13 +3783,19 @@ document.querySelector("#check-env-health")?.addEventListener("click", async () showToast(error instanceof Error ? error.message : String(error), true); } }); -document.querySelector("#cleanup-path")?.addEventListener("click", () => { - if (!window.confirm("将删除当前用户 PATH 中真实失效或重复的条目,并先创建环境备份;受管待安装路径会保留。确定继续吗?")) return; - void runOperation(() => invoke("cleanup_path_entries"), "正在清理真实失效和重复 PATH"); +document.querySelector("#cleanup-path")?.addEventListener("click", async () => { + if (!(await askForConfirmation("将删除当前用户 PATH 中真实失效或重复的条目,并先创建环境备份;受管待安装路径会保留。确定继续吗?"))) return; + void runOperation(async () => { + const token = await riskOperationToken("cleanup_path_entries", "cleanup-path-entries", "medium", false, "environment-backup"); + return invoke("cleanup_path_entries", { confirmationToken: token.token }); + }, "正在清理真实失效和重复 PATH"); }); -document.querySelector("#restore-env")?.addEventListener("click", () => { - if (!window.confirm("将恢复最近一次环境备份;已打开的终端和 IDE 不会自动刷新。确定继续吗?")) return; - void runOperation(() => invoke("restore_user_environment"), "正在恢复用户环境变量"); +document.querySelector("#restore-env")?.addEventListener("click", async () => { + if (!(await askForConfirmation("将恢复最近一次环境备份;已打开的终端和 IDE 不会自动刷新。确定继续吗?"))) return; + void runOperation(async () => { + const token = await riskOperationToken("restore_user_environment", "restore-user-environment-latest", "high", false, "environment-backup"); + return invoke("restore_user_environment", { confirmationToken: token.token }); + }, "正在恢复用户环境变量"); }); document.querySelector("#save-profile")?.addEventListener("click", () => { const input = document.querySelector("#profile-name"); @@ -3645,14 +3809,21 @@ document.querySelector("#export-profiles")?.addEventListener("click", () => { void runOperation(() => invoke("export_config_profiles"), "正在导出配置模板"); }); document.querySelector("#repair-doctor-safe")?.addEventListener("click", async () => { - if (!window.confirm("将自动清理真实失效/重复 PATH,并修复 DevEnv 管理的用户级环境变量。不会安装软件、结束进程或修改系统级变量。确定继续吗?")) return; + if (!(await askForConfirmation("将自动清理真实失效/重复 PATH,并修复 DevEnv 管理的用户级环境变量。不会安装软件、结束进程或修改系统级变量。确定继续吗?"))) return; showToast("正在执行安全修复并重新诊断"); try { const result = await invoke("repair_doctor_safe"); state.doctor = result.report; renderDoctor(); const detail = result.applied.length ? result.applied.join("\n") : "没有可自动修复的安全项目"; - window.alert(`环境评分:${result.beforeScore} → ${result.afterScore}\n\n${detail}${result.remaining.length ? `\n\n仍需手动处理 ${result.remaining.length} 项。` : ""}`); + const repairResult = document.querySelector("#doctor-repair-result"); + if (repairResult) { + repairResult.innerHTML = `
    +
    安全修复结果${result.beforeScore} → ${result.afterScore}
    + ${escapeHtml(detail)} + ${result.remaining.length ? `
      ${result.remaining.map((item) => `
    • ${escapeHtml(item)}
    • `).join("")}
    ` : "没有剩余需要手动处理的自动修复项"} +
    `; + } showToast(`安全修复完成,当前评分 ${result.afterScore}`); await refreshBase(); } catch (error) { @@ -3680,14 +3851,14 @@ document.querySelector("#preview-profiles")?.addEventListener("click", async () showToast(error instanceof Error ? error.message : String(error), true); } }); -document.querySelector("#import-profiles")?.addEventListener("click", () => { +document.querySelector("#import-profiles")?.addEventListener("click", async () => { const path = document.querySelector("#profile-file-path")?.value.trim() || ""; if (!path || !state.profileImportPreview) { showToast("请先预览并校验模板", true); return; } const replacements = state.profileImportPreview.profiles.filter((item) => item.willReplace).length; - if (!window.confirm(`将导入 ${state.profileImportPreview.profiles.length} 个模板${replacements ? `,覆盖 ${replacements} 个同名模板` : ""}。确定继续吗?`)) return; + if (!(await askForConfirmation(`将导入 ${state.profileImportPreview.profiles.length} 个模板${replacements ? `,覆盖 ${replacements} 个同名模板` : ""}。确定继续吗?`))) return; void runOperation(() => invoke("import_config_profiles", { path }), "正在导入配置模板").then(() => { state.profileImportPreview = null; renderProfileImportPreview(); @@ -3707,9 +3878,12 @@ document.querySelector("#load-cache")?.addEventListener("click", async () => { state.cache = await invoke("cache_entries", { calculateHash: false }); renderCache(); }); -document.querySelector("#clear-cache")?.addEventListener("click", () => { - if (!window.confirm("下载缓存将逐项移入 Windows 回收站,不会删除受管运行时或配置。确定继续吗?")) return; - void runOperation(() => invoke("clear_download_cache"), "正在将下载缓存移入回收站"); +document.querySelector("#clear-cache")?.addEventListener("click", async () => { + if (!(await askForConfirmation("下载缓存将逐项移入 Windows 回收站,不会删除受管运行时或配置。确定继续吗?"))) return; + void runOperation(async () => { + const token = await riskOperationToken("clear_download_cache", "clear-download-cache", "medium"); + return invoke("clear_download_cache", { confirmationToken: token.token }); + }, "正在将下载缓存移入回收站"); }); document.querySelector("#inspect-maintenance")?.addEventListener("click", () => void inspectMaintenance()); document.querySelector("#scan-maintenance")?.addEventListener("click", () => void scanMaintenance()); @@ -3758,7 +3932,7 @@ document.addEventListener("change", (event) => { document.querySelector("#execute-cleanup-plan")?.addEventListener("click", async () => { const plan = state.cleanupPlan; if (!plan) return; - if (!window.confirm(`即将清理 ${plan.selectedItems.length} 项,预计释放 ${formatBytes(plan.estimatedBytes)}。后端会再次扫描并校验,普通文件移入回收站。确定继续吗?`)) return; + if (!(await askForConfirmation(`即将清理 ${plan.selectedItems.length} 项,预计释放 ${formatBytes(plan.estimatedBytes)}。后端会再次扫描并校验,普通文件移入回收站。确定继续吗?`))) return; showToast("正在重新校验并执行清理计划"); try { state.cleanupResult = await invoke("clean_selected_targets", { plan }); @@ -3816,7 +3990,7 @@ document.querySelector("#scan-large-files")?.addEventListener("click", async () document.querySelector("#scan-duplicates")?.addEventListener("click", async () => { const root = document.querySelector("#duplicate-root")?.value.trim() || ""; const minSizeMb = Number(document.querySelector("#duplicate-min")?.value || "10"); - if (!window.confirm(`将只在“${root || "用户目录"}”内对 ${minSizeMb} MB 以上、大小相同的候选文件计算 SHA256。不会上传或删除文件,确定继续吗?`)) return; + if (!(await askForConfirmation(`将只在“${root || "用户目录"}”内对 ${minSizeMb} MB 以上、大小相同的候选文件计算 SHA256。不会上传或删除文件,确定继续吗?`))) return; showToast("正在按大小分组并计算重复候选 SHA256"); setScanBusy("#scan-duplicates", "#cancel-duplicate-scan", true); try { @@ -3892,7 +4066,7 @@ document.querySelector("#preview-downloads-archive")?.addEventListener("click", document.querySelector("#execute-move-plan")?.addEventListener("click", async () => { const plan = state.movePlan; if (!plan) return; - if (!window.confirm(`将执行 ${plan.mode}:\n${plan.source}\n→ ${plan.target}\n\n执行前请关闭相关程序。确定继续吗?`)) return; + if (!(await askForConfirmation(`将执行 ${plan.mode}:\n${plan.source}\n→ ${plan.target}\n\n执行前请关闭相关程序。确定继续吗?`))) return; showToast("正在执行空间搬家/归档计划"); try { const command = plan.source.toLowerCase().includes("\\desktop") && plan.mode === "archive_only" @@ -3900,7 +4074,8 @@ document.querySelector("#execute-move-plan")?.addEventListener("click", async () : plan.source.toLowerCase().includes("\\downloads") && plan.mode === "archive_only" ? "execute_downloads_archive_plan" : "execute_move_plan"; - state.moveResult = await invoke(command, { plan }); + const token = await riskOperationToken("execute_move_plan", plan.planId, "high", false, "move-plan-preview"); + state.moveResult = await invoke(command, { plan, confirmationToken: token.token }); renderMovePlan(); await loadRollbackRecords(); showToast(`执行完成:${formatBytes(state.moveResult.movedBytes)},失败 ${state.moveResult.failures.length} 项`, state.moveResult.failures.length > 0); @@ -3939,14 +4114,15 @@ document.querySelector("#execute-expansion-plan")?.addEventListener("click", asy "最后确认:执行期间不要断电,不要关闭程序。输入 YES 执行。", ]; for (const prompt of prompts) { - if (window.prompt(prompt) !== "YES") { + if (!(await askForConfirmation(prompt, { title: "确认磁盘扩容操作", danger: true, requiredText: "YES" }))) { showToast("已取消扩容执行"); return; } } showToast("正在执行 C 盘扩容计划"); try { - state.expansionResult = await invoke("execute_c_drive_expansion", { plan }); + const token = await riskOperationToken("execute_expansion_plan", plan.planId, "critical", true, "manual-backup-confirmed"); + state.expansionResult = await invoke("execute_c_drive_expansion", { plan, confirmationToken: token.token }); renderExpansionPlan(); showToast(state.expansionResult.success ? "扩容执行完成" : "扩容未成功,请查看报告", !state.expansionResult.success); } catch (error) { @@ -3954,18 +4130,7 @@ document.querySelector("#execute-expansion-plan")?.addEventListener("click", asy } }); document.querySelector("#check-updates")?.addEventListener("click", async () => { - showToast("正在检查新版本"); - try { - state.update = await invoke("check_for_updates"); - state.updateError = ""; - window.localStorage.setItem("devenv-last-update-check", String(Date.now())); - renderUpdate(); - showToast(state.update.updateAvailable ? `发现新版本 ${state.update.latestVersion}` : "当前已是最新版本"); - } catch (error) { - state.updateError = error instanceof Error ? error.message : String(error); - renderUpdate(); - showToast(state.updateError, true); - } + await checkUpdates(); }); document.querySelector("#auto-check-updates")?.addEventListener("change", (event) => { const enabled = (event.target as HTMLInputElement).checked; @@ -4009,8 +4174,8 @@ document.querySelector("#inspect-mysql-repair")?.addEventListener("click", async document.querySelector("#open-docker-desktop")?.addEventListener("click", () => { void runOperation(() => invoke("open_docker_desktop"), "正在启动 Docker Desktop"); }); -document.querySelector("#self-uninstall")?.addEventListener("click", () => { - const ok = window.confirm("这会启动 DevEnv Manager 的卸载程序并关闭当前程序。确定继续吗?"); +document.querySelector("#self-uninstall")?.addEventListener("click", async () => { + const ok = await askForConfirmation("这会启动 DevEnv Manager 的卸载程序并关闭当前程序。确定继续吗?"); if (!ok) return; void runOperation(() => invoke("self_uninstall"), "正在启动卸载程序"); }); @@ -4025,7 +4190,10 @@ document.querySelector("#run-command")?.addEventListener("click", async () => { } let confirmed = false; if (assessment.requiresConfirmation) { - confirmed = window.confirm(`${assessment.reason}\n\n命令:${command}\n\n确定继续吗?`); + confirmed = await askForConfirmation(`${assessment.reason}\n\n命令:${command}\n\n确定继续吗?`, { + title: "确认运行白名单命令", + danger: assessment.risk === "high" || assessment.risk === "critical", + }); if (!confirmed) return; } showToast(`正在运行白名单命令:${assessment.executable}`); @@ -4157,7 +4325,7 @@ document.querySelectorAll(".sort-head").forEach((button) => { }); }); -document.addEventListener("click", (event) => { +document.addEventListener("click", async (event) => { const button = (event.target as HTMLElement).closest( "button[data-action], button[data-toolchain-action], button[data-python-tool], button[data-page-key], button[data-dev-cache], button[data-chsrc-action], button[data-cleanup-report-action], button[data-restore-env-backup], button[data-mysql-action], #apply-project-config, #apply-environment-preview, #apply-python-repair, #create-managed-python-pip-plan, #execute-mysql-plan, #accept-safety-disclaimer", ); @@ -4177,7 +4345,7 @@ document.addEventListener("click", (event) => { if (button.id === "apply-python-repair") { const plan = state.pythonRepairPlan; if (!plan) return; - if (!window.confirm(`将执行 ${plan.actions.length} 项 Python 修复,并先保存用户环境备份。pip 升级可能联网,确定继续吗?`)) return; + if (!(await askForConfirmation(`将执行 ${plan.actions.length} 项 Python 修复,并先保存用户环境备份。pip 升级可能联网,确定继续吗?`))) return; void runOperation(() => invoke("apply_python_repair", { planId: plan.planId }), "正在执行并验证 Python 修复").then(async () => { state.pythonRepairPlan = null; state.python = await invoke("analyze_python_environment"); @@ -4191,7 +4359,10 @@ document.addEventListener("click", (event) => { if (!plan) return; const backupDestination = document.querySelector("#mysql-backup-destination")?.value.trim() || null; const guideOnly = plan.action === "reset_root_guide" || plan.action === "dump_guide"; - if (!guideOnly && !window.confirm(`将执行 MySQL 计划“${plan.title}”。程序会重新诊断路径和状态;失败不会绕过保护规则。确定继续吗?`)) return; + if (!guideOnly && !(await askForConfirmation(`将执行 MySQL 计划“${plan.title}”。程序会重新诊断路径和状态;失败不会绕过保护规则。确定继续吗?`, { + title: "确认 MySQL 修复计划", + danger: true, + }))) return; void (async () => { showToast(guideOnly ? "正在生成安全向导" : "正在执行 MySQL 修复计划"); try { @@ -4199,13 +4370,13 @@ document.addEventListener("click", (event) => { if (!guideOnly) { const guard = await invoke("mysql_pending_execution_guard", { planId: plan.planId }); if (guard.riskLevel === "critical") { - if (!window.confirm("第一次确认:MySQL 系统库修复前必须已完成完整 Data 备份。")) return; - if (!window.confirm("第二次确认:我理解该操作可能影响数据库服务启动和业务库恢复。")) return; - const phrase = window.prompt("第三次确认:请输入“我已知晓 MySQL 修复风险并确认执行”"); - if (phrase !== "我已知晓 MySQL 修复风险并确认执行") { - showToast("三次确认文本不匹配,已取消", true); - return; - } + if (!(await askForConfirmation("第一次确认:MySQL 系统库修复前必须已完成完整 Data 备份。"))) return; + if (!(await askForConfirmation("第二次确认:我理解该操作可能影响数据库服务启动和业务库恢复。"))) return; + if (!(await askForConfirmation("第三次确认:请输入指定文本后才允许继续。", { + title: "最终确认 MySQL 高危修复", + danger: true, + requiredText: "我已知晓 MySQL 修复风险并确认执行", + }))) return; } const token = await createBackendConfirmation( guard.actionId, @@ -4214,6 +4385,7 @@ document.addEventListener("click", (event) => { guard.planFingerprint, guard.riskLevel === "critical", guard.backupReceipt || null, + "execute_mysql_repair_plan", ); confirmationToken = token.token; } @@ -4250,8 +4422,8 @@ document.addEventListener("click", (event) => { else if (pageKey === "project-ports") renderProjectPortConfigs(); else if (pageKey.startsWith("project-") && state.project) renderProjectAnalysis(state.project); else if (pageKey === "environment-backups") renderEnvironmentBackups(); - else if (pageKey === "desktop-usage") renderFolderUsage("#desktop-usage", state.desktopUsage, "desktop-usage"); - else if (pageKey === "downloads-usage") renderFolderUsage("#downloads-usage", state.downloadsUsage, "downloads-usage"); + else if (pageKey.startsWith("desktop-usage")) renderFolderUsage("#desktop-usage", state.desktopUsage, "desktop-usage"); + else if (pageKey.startsWith("downloads-usage")) renderFolderUsage("#downloads-usage", state.downloadsUsage, "downloads-usage"); else if (pageKey === "large-files") renderLargeFiles(); else if (pageKey === "archive-plan") renderArchivePlan(); else if (pageKey === "rollback-records") renderRollbackRecords(); @@ -4265,8 +4437,11 @@ document.addEventListener("click", (event) => { } const devCache = button.dataset.devCache; if (devCache) { - if (!window.confirm(`将调用 ${button.title || button.textContent || devCache}。该命令会清除可重新生成的开发缓存,确定继续吗?`)) return; - void runOperation(() => invoke("clean_dev_cache", { tool: devCache }), `正在使用 ${devCache} 官方命令清理缓存`).then(() => void scanMaintenance()); + if (!(await askForConfirmation(`将调用 ${button.title || button.textContent || devCache}。该命令会清除可重新生成的开发缓存,确定继续吗?`))) return; + void runOperation(async () => { + const token = await riskOperationToken("clean_dev_cache", `tool-${devCache.trim().toLowerCase()}`, "medium"); + return invoke("clean_dev_cache", { tool: devCache, confirmationToken: token.token }); + }, `正在使用 ${devCache} 官方命令清理缓存`).then(() => void scanMaintenance()); return; } const chsrcAction = button.dataset.chsrcAction; @@ -4274,7 +4449,10 @@ document.addEventListener("click", (event) => { const target = document.querySelector("#chsrc-target")?.value || "node"; const source = document.querySelector("#chsrc-source")?.value.trim() || null; const changing = ["auto", "set", "reset"].includes(chsrcAction); - if (changing && !window.confirm(`将调用官方 chsrc 对 ${target} 执行 ${chsrcAction},可能修改当前用户或工具配置。确定继续吗?`)) return; + if (changing && !(await askForConfirmation(`将调用官方 chsrc 对 ${target} 执行 ${chsrcAction},可能修改当前用户或工具配置。确定继续吗?`, { + title: "确认 chsrc 配置操作", + danger: true, + }))) return; void (async () => { try { const result = await invoke("run_chsrc_action", { action: chsrcAction, target, source }); @@ -4326,7 +4504,7 @@ document.addEventListener("click", (event) => { } if (button.dataset.action === "restore-env-record") { const backupName = button.dataset.backupName || ""; - if (!confirmRisk(`将恢复用户级环境变量备份:${backupName}\n恢复前会先备份当前状态。`, "medium")) return; + if (!(await confirmRisk(`将恢复用户级环境变量备份:${backupName}\n恢复前会先备份当前状态。`, "medium"))) return; void invoke("restore_env_backup", { backupName }) .then(async (result) => { state.envRepairResult = result; @@ -4342,8 +4520,11 @@ document.addEventListener("click", (event) => { } if (button.dataset.action === "rollback-move") { const rollbackId = button.dataset.rollbackId || ""; - if (!window.confirm(`将执行回滚 ${rollbackId}:删除 Junction 并恢复备份目录(如存在)。确定继续吗?`)) return; - void runOperation(() => invoke("rollback_move", { rollbackId }), "正在执行空间搬家回滚").then(() => void loadRollbackRecords()); + if (!(await askForConfirmation(`将执行回滚 ${rollbackId}:删除 Junction 并恢复备份目录(如存在)。确定继续吗?`))) return; + void runOperation(async () => { + const token = await riskOperationToken("rollback_move", rollbackId, "high"); + return invoke("rollback_move", { rollbackId, confirmationToken: token.token }); + }, "正在执行空间搬家回滚").then(() => void loadRollbackRecords()); return; } if (button.id === "apply-project-config") { @@ -4359,9 +4540,13 @@ document.addEventListener("click", (event) => { }); const enabled = preview.files.filter((file) => file.enabled).length; const switchCount = Object.keys(switches).length; - if (!window.confirm(`将写入 ${enabled} 个固定项目配置文件并切换 ${switchCount} 个运行时。已有文件和切换前环境都会备份,确定继续吗?`)) return; + if (!(await askForConfirmation(`将写入 ${enabled} 个固定项目配置文件并切换 ${switchCount} 个运行时。已有文件和切换前环境都会备份,确定继续吗?`))) return; void runOperation( - () => invoke("apply_project_configuration", { request: { projectPath: preview.projectPath, files: preview.files, switches } }), + async () => { + const request = { projectPath: preview.projectPath, files: preview.files, switches }; + const token = await riskOperationToken("apply_project_configuration", projectConfigurationPlanId(preview.projectPath, enabled, switchCount), "high", false, "project-backup"); + return invoke("apply_project_configuration", { request, confirmationToken: token.token }); + }, "正在备份并应用项目配置", ); return; @@ -4369,9 +4554,12 @@ document.addEventListener("click", (event) => { if (button.id === "apply-environment-preview") { const preview = state.environmentPreview; if (!preview) return; - if (!window.confirm(`将按预览写入 ${preview.changes.length} 组当前用户环境配置,并先保存 ${preview.backupName}。确定继续吗?`)) return; + if (!(await askForConfirmation(`将按预览写入 ${preview.changes.length} 组当前用户环境配置,并先保存 ${preview.backupName}。确定继续吗?`))) return; void runOperation( - () => invoke("apply_user_environment_configuration", { previewId: preview.previewId }), + async () => { + const token = await riskOperationToken("apply_user_environment_configuration", preview.previewId, "high", false, preview.backupName); + return invoke("apply_user_environment_configuration", { previewId: preview.previewId, confirmationToken: token.token }); + }, "正在备份、写入并回读验证用户环境变量", ).then(async () => { state.environmentPreview = null; @@ -4383,7 +4571,7 @@ document.addEventListener("click", (event) => { } const restoreBackup = button.dataset.restoreEnvBackup; if (restoreBackup) { - if (!window.confirm(`将恢复环境备份 ${restoreBackup};恢复前会再保存当前状态。确定继续吗?`)) return; + if (!(await askForConfirmation(`将恢复环境备份 ${restoreBackup};恢复前会再保存当前状态。确定继续吗?`))) return; void runOperation( () => invoke("restore_environment_backup", { fileName: restoreBackup }), "正在恢复指定环境备份", @@ -4422,6 +4610,31 @@ document.addEventListener("click", (event) => { .catch((error) => showToast(error instanceof Error ? error.message : String(error), true)); return; } + if (action === "rescan-folder") { + const target = button.dataset.target || ""; + if (target === "desktop") { + showToast("正在重新扫描桌面"); + void invoke("inspect_desktop") + .then((report) => { + state.desktopUsage = report; + renderFolderUsage("#desktop-usage", report, "desktop-usage"); + showToast("桌面明细已刷新"); + }) + .catch((error) => showToast(error instanceof Error ? error.message : String(error), true)); + } else if (target === "downloads") { + showToast("正在重新扫描下载目录"); + void invoke("inspect_downloads") + .then((report) => { + state.downloadsUsage = report; + renderFolderUsage("#downloads-usage", report, "downloads-usage"); + showToast("下载目录明细已刷新"); + }) + .catch((error) => showToast(error instanceof Error ? error.message : String(error), true)); + } else if (target === "large-files") { + document.querySelector("#scan-large-files")?.click(); + } + return; + } if (action === "open-apps-features") { void invoke("open_apps_features") .then((result) => showToast(result.message)) @@ -4469,6 +4682,15 @@ document.addEventListener("click", (event) => { void copyText(`DevEnv Manager safe mode\n${state.fatalError}`); return; } + if (action === "dismiss-safe-mode-banner") { + state.safeModeNoticeCollapsed = true; + renderFatalError(); + return; + } + if (action === "hide-toast") { + hideToast(); + return; + } if (action === "copy-safety-disclaimer") { void copyText(state.safetyDisclaimer || "DevEnv Manager safety disclaimer"); return; @@ -4477,10 +4699,28 @@ document.addEventListener("click", (event) => { void inspectPlatforms(); return; } - if (action === "feature-help-expand") { - const view = button.dataset.helpView || document.querySelector(".nav-item.active")?.getAttribute("data-view") || "overview"; - setFeatureHelpCollapsed(view, false); - renderFeatureHelp(view); + if (action === "check-updates") { + void checkUpdates(); + return; + } + if (action === "verify-external-jdk") { + const jdkPath = button.dataset.jdkPath || ""; + showToast("正在只读验证 JDK 的 java、javac 和 jar"); + void invoke("verify_external_jdk", { jdkPath }) + .then((checks) => { + state.externalJdkChecks[jdkPath] = checks; + renderJavaEnvironment(); + showToast(checks.every((item) => item.success) ? "外部 JDK 验证通过" : "外部 JDK 验证未完全通过", !checks.every((item) => item.success)); + }) + .catch((error) => showToast(error instanceof Error ? error.message : String(error), true)); + return; + } + if (action === "set-java-home-candidate") { + const jdkPath = button.dataset.jdkPath || ""; + const input = document.querySelector("#java-stabilize-path"); + if (input) input.value = jdkPath; + activateView("environment"); + document.querySelector("#create-java-stabilize-plan")?.click(); return; } if (action === "doctor-fix") { @@ -4508,9 +4748,13 @@ document.addEventListener("click", (event) => { wsl_terminate: `终止 WSL 发行版 ${value || ""}`, wsl_set_default: `将 ${value || ""} 设为默认发行版`, }; - if (!window.confirm(`${labels[platformAction] || "执行平台操作"}。需要管理员权限时 Windows 会显示 UAC,确定继续吗?`)) return; + if (!(await askForConfirmation(`${labels[platformAction] || "执行平台操作"}。需要管理员权限时 Windows 会显示 UAC,确定继续吗?`))) return; void runOperation( - () => invoke("manage_system_platform", { action: platformAction, value: value || null }), + async () => { + const planId = `${platformAction}:${value || ""}`; + const token = await riskOperationToken("manage_system_platform", planId, "high"); + return invoke("manage_system_platform", { action: platformAction, value: value || null, confirmationToken: token.token }); + }, `正在${labels[platformAction] || "执行平台操作"}`, ).then(async () => { state.systemPlatforms = await invoke("inspect_system_platforms"); @@ -4519,7 +4763,7 @@ document.addEventListener("click", (event) => { } if (action === "download-update") { void (async () => { - if (!window.confirm("将从 GitHub Releases 下载新版安装包,并使用发布清单中的 SHA256 校验。确定继续吗?")) return; + if (!(await askForConfirmation("将从 GitHub Releases 下载新版安装包,并使用发布清单中的 SHA256 校验。确定继续吗?"))) return; showToast("正在下载并校验更新安装包"); try { const result = await invoke("download_update"); @@ -4533,7 +4777,7 @@ document.addEventListener("click", (event) => { })(); } if (action === "install-update") { - if (!window.confirm("将启动已校验的安装器并退出当前程序。请保存正在进行的工作,确定继续吗?")) return; + if (!(await askForConfirmation("将启动已校验的安装器并退出当前程序。请保存正在进行的工作,确定继续吗?"))) return; void (async () => { showToast("正在重新校验并启动更新安装器"); try { @@ -4553,9 +4797,12 @@ document.addEventListener("click", (event) => { showToast("请输入 1024 到 65535 之间的有效端口", true); return; } - if (!window.confirm(`将备份 ${config.file},并把端口 ${config.currentPort} 修改为 ${newPort}。确定继续吗?`)) return; + if (!(await askForConfirmation(`将备份 ${config.file},并把端口 ${config.currentPort} 修改为 ${newPort}。确定继续吗?`))) return; void runOperation( - () => invoke("update_project_port", { path, configId, newPort }), + async () => { + const token = await riskOperationToken("update_project_port", `${path}:${configId}:${newPort}`, "medium", false, "project-port-backup"); + return invoke("update_project_port", { path, configId, newPort, confirmationToken: token.token }); + }, "正在备份并修改项目端口", ).then(() => void inspectProjectPorts(false)); } @@ -4573,9 +4820,12 @@ document.addEventListener("click", (event) => { const serviceName = button.dataset.service || ""; const serviceAction = button.dataset.serviceAction || ""; const actionLabel = serviceAction === "start" ? "启动" : serviceAction === "stop" ? "停止" : "重启"; - if (!window.confirm(`将${actionLabel} Windows 服务 ${serviceName}。数据库连接可能短暂中断,确定继续吗?`)) return; + if (!(await askForConfirmation(`将${actionLabel} Windows 服务 ${serviceName}。数据库连接可能短暂中断,确定继续吗?`))) return; void runOperation( - () => invoke("manage_local_service", { serviceName, action: serviceAction }), + async () => { + const token = await riskOperationToken("manage_local_service", `${serviceName}:${serviceAction}`, "high"); + return invoke("manage_local_service", { serviceName, action: serviceAction, confirmationToken: token.token }); + }, `正在${actionLabel}服务 ${serviceName}`, ).then(async () => { state.localServices = await invoke("inspect_local_services"); @@ -4604,10 +4854,13 @@ document.addEventListener("click", (event) => { if (action === "stop-local-service") { const port = Number(button.dataset.port || 0); const serviceName = button.dataset.service || ""; - const ok = window.confirm(`将停止 Windows 服务 ${serviceName}(端口 ${port})。这会中断当前数据库连接,确定继续吗?`); + const ok = await askForConfirmation(`将停止 Windows 服务 ${serviceName}(端口 ${port})。这会中断当前数据库连接,确定继续吗?`); if (!ok) return; void runOperation( - () => invoke("stop_local_service", { port, serviceName }), + async () => { + const token = await riskOperationToken("stop_local_service", `${port}:${serviceName}`, "high"); + return invoke("stop_local_service", { port, serviceName, confirmationToken: token.token }); + }, `正在停止服务 ${serviceName}`, ).then(async () => { state.localServices = await invoke("inspect_local_services"); @@ -4635,7 +4888,7 @@ document.addEventListener("click", (event) => { return; } const longRunning = projectAction === "npm_dev" || projectAction === "npm_tauri_dev"; - const ok = longRunning || window.confirm(`将运行:${command}\n\n工作目录:${input.value}\n\n确定继续吗?`); + const ok = longRunning || (await askForConfirmation(`将运行:${command}\n\n工作目录:${input.value}\n\n确定继续吗?`)); if (!ok) return; showToast(longRunning ? "正在后台启动开发服务" : "正在运行项目命令"); void invoke("run_project_action", { path: input.value, action: projectAction }) @@ -4652,7 +4905,7 @@ document.addEventListener("click", (event) => { const path = button.dataset.path || null; const currentHome = state.javaEnvironment?.javaHome || state.env?.javaHome || "未设置"; const targetHome = path || `%DEVENV_HOME%\\current\\jdk(JDK ${version})`; - if (!window.confirm(`将修改当前用户的 JDK 生效链:\n\nJAVA_HOME:${currentHome}\n→ ${targetHome}\n\n受管 PATH 中的 JDK 会保持在首位;切换后将自动验证 java、javac、Maven 与 Gradle。确定继续吗?`)) return; + if (!(await askForConfirmation(`将修改当前用户的 JDK 生效链:\n\nJAVA_HOME:${currentHome}\n→ ${targetHome}\n\n受管 PATH 中的 JDK 会保持在首位;切换后将自动验证 java、javac、Maven 与 Gradle。确定继续吗?`))) return; void runRuntimeOperation( () => invoke("switch_runtime", { kind: "jdk", version, path }), `正在切换 JDK ${version}`, @@ -4754,7 +5007,7 @@ document.addEventListener("click", (event) => { const message = missing.length ? `将联网安装:${missing.map((item) => `${item.kind} ${item.version}`).join("、")},安装完成后应用模板。确定继续吗?` : "所需运行时均已安装,将直接应用模板。确定继续吗?"; - if (!window.confirm(message)) return; + if (!(await askForConfirmation(message))) return; await runRuntimeOperation( () => invoke("install_profile_missing", { id }), missing.length ? "正在补齐模板所需运行时" : "正在应用配置模板", @@ -4786,13 +5039,6 @@ window.addEventListener("unhandledrejection", (event) => { enterSafeMode(event.reason, "未处理的异步错误"); }); -document.addEventListener("change", (event) => { - const input = (event.target as HTMLElement).closest('input[data-action="feature-help-collapse-next"]'); - if (!input) return; - const view = input.dataset.helpView || document.querySelector(".nav-item.active")?.getAttribute("data-view") || "overview"; - setFeatureHelpCollapsed(view, input.checked); -}); - window.addEventListener( "keydown", (event) => { @@ -4848,3 +5094,4 @@ document.querySelector("#inspect-agent-traces")?.addEventListener("click", async showToast(error instanceof Error ? error.message : String(error), true); } }); + diff --git a/tauri/src/styles.css b/tauri/src/styles.css index 2e37db3..3e497df 100644 --- a/tauri/src/styles.css +++ b/tauri/src/styles.css @@ -249,21 +249,91 @@ svg { overflow-wrap: anywhere; } -.folder-usage-grid { +.folder-usage-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 10px; - margin: 12px 0; + margin: 12px 0 16px; + padding: 14px; + border: 1px solid #d7e2e6; + border-radius: 8px; + background: #f8fafb; +} + +.folder-usage-summary > div:first-child { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.folder-usage-summary strong { + font-size: 18px; +} + +.folder-usage-summary > div:first-child span { + color: #17633f; + font-weight: 700; +} + +.folder-usage-summary small { + color: #65727c; + overflow-wrap: anywhere; +} + +.folder-usage-notes { + display: grid; + gap: 6px; +} + +.folder-usage-notes span { + display: block; + color: #52616b; + line-height: 1.5; +} + +.folder-usage-section { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.section-heading h3 { + margin: 0; + font-size: 15px; +} + +.section-heading small, +.section-heading > span { + color: #65727c; +} + +.folder-usage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; } .folder-usage-card { padding: 12px; border: 1px solid #d7dfe5; - border-radius: 10px; + border-radius: 8px; background: #ffffff; } -.folder-usage-card > div:first-child { +.folder-usage-card summary { + cursor: pointer; + list-style-position: outside; +} + +.folder-usage-card > div:first-child, +.folder-usage-card summary > div:first-child { display: flex; justify-content: space-between; gap: 12px; @@ -273,6 +343,98 @@ svg { display: block; margin-top: 7px; color: #65727c; + line-height: 1.55; +} + +.nested-panel { + margin-top: 12px; + border-radius: 8px; +} + +.compact-file-list { + gap: 8px; +} + +.file-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); + gap: 12px; +} + +.file-detail-card { + overflow: hidden; + align-content: start; + background: #ffffff; +} + +.file-detail-card strong, +.file-detail-card small { + min-width: 0; + overflow-wrap: anywhere; +} + +.file-detail-card small { + line-height: 1.5; +} + +.file-detail-card.missing-file { + border-color: #f0c4bc; + background: #fffafa; +} + +.file-detail-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; +} + +.file-detail-head strong { + font-size: 14px; + line-height: 1.45; +} + +.file-detail-head span { + color: #17633f; + font-weight: 700; + white-space: nowrap; +} + +.file-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.file-detail-meta span { + padding: 3px 7px; + border: 1px solid #dbe4ea; + border-radius: 999px; + background: #f6f8fa; + color: #50606d; + font-size: 12px; +} + +.file-actions { + margin: 2px 0 0; +} + +.inline-archive-action { + margin: -6px 0 10px; +} + +.copyable-kv { + display: grid; + grid-template-columns: minmax(72px, auto) minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.copyable-kv strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .maintenance-panel .form-row > span { @@ -333,6 +495,9 @@ svg { right: 24px; bottom: 24px; z-index: 1000; + display: flex; + align-items: flex-start; + gap: 10px; width: min(430px, calc(100vw - 48px)); margin: 0; padding: 10px 12px; @@ -343,12 +508,64 @@ svg { box-shadow: 0 10px 32px rgba(20, 43, 35, 0.18); } +.toast[hidden] { + display: none; +} + +.toast-content { + flex: 1; + min-width: 0; +} + +.toast-message { + display: block; + overflow-wrap: anywhere; + line-height: 1.5; +} + +.toast details summary { + cursor: pointer; + overflow-wrap: anywhere; + line-height: 1.5; +} + +.toast pre { + max-height: 180px; + margin: 8px 0 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.toast-close, +.fatal-error-heading > button { + width: 28px; + height: 28px; + flex: 0 0 auto; + padding: 0; + border-radius: 50%; + border-color: transparent; + background: transparent; + color: inherit; +} + +.toast-close:hover, +.fatal-error-heading > button:hover { + background: rgba(15, 23, 42, 0.08); +} + .toast.error { border-color: #f0c4bc; background: #fff0ed; color: #9a321f; } +.toast.warning { + border-color: #facc15; + background: #fef9c3; + color: #854d0e; +} + .fatal-error { position: sticky; top: 12px; @@ -370,6 +587,13 @@ svg { padding: 18px; } +.fatal-error-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + .fatal-error-card h2, .safety-gate-card h2 { margin: 0 0 6px; @@ -401,6 +625,59 @@ svg { gap: 10px; } +.confirm-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.58); +} + +.confirm-dialog { + display: grid; + gap: 14px; + width: min(560px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow: auto; + padding: 20px; + border: 1px solid #d8e2ea; + border-radius: 8px; + background: #ffffff; + color: #17212b; + box-shadow: 0 22px 64px rgba(15, 23, 42, 0.24); +} + +.confirm-dialog h2, +.confirm-dialog p { + margin: 0; +} + +.confirm-dialog p, +.confirm-dialog li, +.confirm-dialog small { + line-height: 1.6; + overflow-wrap: anywhere; +} + +.confirm-required { + display: grid; + gap: 6px; +} + +.confirm-required input.invalid { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12); +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + .safety-gate { position: fixed; inset: 0; @@ -825,13 +1102,19 @@ th { min-height: 20px; margin-top: 4px; padding: 0 7px; + border: 1px solid #f3c36c; border-radius: 999px; background: #fff0d5; color: #8a5a14; + cursor: pointer; font-size: 11px; font-weight: 700; } +.conflict-badge:hover { + background: #ffe2a8; +} + .pill { display: inline-flex; align-items: center; @@ -1494,13 +1777,45 @@ select { cursor: pointer; } -.view-guide p { +.view-guide-body { margin: 0; - padding: 0 12px 12px 38px; + padding: 0 14px 14px 38px; color: #617079; line-height: 1.55; } +.view-guide-body section { + margin: 10px 0 0; +} + +.view-guide-body h3, +.view-guide-body h4 { + margin: 0 0 6px; + color: #2f4d43; +} + +.view-guide-body h3 { + font-size: 15px; +} + +.view-guide-body h4 { + font-size: 13px; +} + +.view-guide-body p { + margin: 0; +} + +.view-guide-body ul, +.view-guide-body ol { + margin: 0; + padding-left: 18px; +} + +.view-guide-body li { + margin: 3px 0; +} + .feature-help-shell { display: grid; gap: 8px; diff --git a/tauri/src/types/index.ts b/tauri/src/types/index.ts index c6bd0c9..9867138 100644 --- a/tauri/src/types/index.ts +++ b/tauri/src/types/index.ts @@ -897,10 +897,18 @@ export type MaintenanceOverview = { export type LargeFileItem = { + fileName: string; path: string; + directory: string; + extension: string; size: number; modifiedAt?: string; fileType: string; + sourceCategory: string; + exists: boolean; + canOpen: boolean; + canLocate: boolean; + openStatus: string; suggestion: string; risk: string; }; @@ -925,7 +933,15 @@ export type FolderUsageReport = { name: string; path: string; totalBytes: number; - categories: Array<{ name: string; path: string; size: number; category: string; suggestion: string }>; + categories: Array<{ + name: string; + path: string; + size: number; + category: string; + suggestion: string; + details: LargeFileItem[]; + }>; + topFiles: LargeFileItem[]; suggestions: string[]; warnings: string[]; }; diff --git a/update-manifest.json b/update-manifest.json index d2f255d..b28dd3f 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,9 +1,9 @@ { - "version": "1.5.3", + "version": "1.6.0", "date": "2026-06-28", "notes": [ - "1.5.3 Stable:基于真实用户反馈的质量收口版本,补全端口管理、外部运行时安全、MySQL 诊断闭环、Python/chsrc 可恢复路径、页面帮助、扫描体验和隐私脱敏。" + "1.6 Stable:补齐高风险后端确认闭环,优化页面使用指南、桌面/下载分页明细、端口与服务安全保护、MySQL 修复执行保护和右下角提示生命周期。" ], - "downloadUrl": "https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.5.3/DevEnv.Manager_1.5.3_x64-setup.exe", - "sha256": "32439838bc46687010b83d4c5f80583fbb0c2b99ed4a836078772184f50e069c" + "downloadUrl": "https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.6/DevEnv.Manager_1.6_x64-setup.exe", + "sha256": "0224a9a6cc3b2004296d2b7c01d6c252a628e9f8fad3254e3dcb9d5bbd2df013" }