diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index fc5a544..6b12850 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -1,3 +1,4 @@ +use crate::error::format_skill_error; use crate::services::skill::SkillState; use crate::services::{Skill, SkillRepo, SkillService}; use crate::store::AppState; @@ -45,18 +46,36 @@ pub async fn install_skill( let skill = skills .iter() .find(|s| s.directory.eq_ignore_ascii_case(&directory)) - .ok_or_else(|| "技能不存在".to_string())?; + .ok_or_else(|| { + format_skill_error( + "SKILL_NOT_FOUND", + &[("directory", &directory)], + Some("checkRepoUrl"), + ) + })?; if !skill.installed { let repo = SkillRepo { owner: skill .repo_owner .clone() - .ok_or_else(|| "缺少仓库信息".to_string())?, + .ok_or_else(|| { + format_skill_error( + "MISSING_REPO_INFO", + &[("directory", &directory), ("field", "owner")], + None, + ) + })?, name: skill .repo_name .clone() - .ok_or_else(|| "缺少仓库信息".to_string())?, + .ok_or_else(|| { + format_skill_error( + "MISSING_REPO_INFO", + &[("directory", &directory), ("field", "name")], + None, + ) + })?, branch: skill .repo_branch .clone() diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 455e907..d9b0262 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -94,3 +94,28 @@ impl From for String { err.to_string() } } + +/// 格式化为 JSON 错误字符串,前端可解析为结构化错误 +pub fn format_skill_error( + code: &str, + context: &[(&str, &str)], + suggestion: Option<&str>, +) -> String { + use serde_json::json; + + let mut ctx_map = serde_json::Map::new(); + for (key, value) in context { + ctx_map.insert(key.to_string(), json!(value)); + } + + let error_obj = json!({ + "code": code, + "context": ctx_map, + "suggestion": suggestion, + }); + + serde_json::to_string(&error_obj).unwrap_or_else(|_| { + // 如果 JSON 序列化失败,返回简单格式 + format!("ERROR:{}", code) + }) +} diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f32a186..1c06f73 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -7,6 +7,8 @@ use std::fs; use std::path::{Path, PathBuf}; use tokio::time::timeout; +use crate::error::format_skill_error; + /// 技能对象 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Skill { @@ -133,7 +135,11 @@ impl SkillService { } fn get_install_dir() -> Result { - let home = dirs::home_dir().context("无法获取用户主目录")?; + let home = dirs::home_dir().context(format_skill_error( + "GET_HOME_DIR_FAILED", + &[], + Some("checkPermission"), + ))?; Ok(home.join(".claude").join("skills")) } } @@ -173,9 +179,19 @@ impl SkillService { /// 从仓库获取技能列表 async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result> { // 为单个仓库加载增加整体超时,避免无效链接长时间阻塞 - let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo)) + let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo)) .await - .map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??; + .map_err(|_| { + anyhow!(format_skill_error( + "DOWNLOAD_TIMEOUT", + &[ + ("owner", &repo.owner), + ("name", &repo.name), + ("timeout", "60") + ], + Some("checkNetwork"), + )) + })??; let mut skills = Vec::new(); // 确定要扫描的目录路径 @@ -379,7 +395,17 @@ impl SkillService { // 下载 ZIP let response = self.http_client.get(url).send().await?; if !response.status().is_success() { - return Err(anyhow::anyhow!("下载失败: {}", response.status())); + let status = response.status().as_u16().to_string(); + return Err(anyhow::anyhow!(format_skill_error( + "DOWNLOAD_FAILED", + &[("status", &status)], + match status.as_str() { + "403" => Some("http403"), + "404" => Some("http404"), + "429" => Some("http429"), + _ => Some("checkNetwork"), + }, + ))); } let bytes = response.bytes().await?; @@ -394,7 +420,11 @@ impl SkillService { let name = first_file.name(); name.split('/').next().unwrap_or("").to_string() } else { - return Err(anyhow::anyhow!("空的压缩包")); + return Err(anyhow::anyhow!(format_skill_error( + "EMPTY_ARCHIVE", + &[], + Some("checkRepoUrl"), + ))); }; // 解压所有文件 @@ -441,11 +471,21 @@ impl SkillService { // 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程 let temp_dir = timeout( - std::time::Duration::from_secs(15), + std::time::Duration::from_secs(60), self.download_repo(&repo), ) .await - .map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??; + .map_err(|_| { + anyhow!(format_skill_error( + "DOWNLOAD_TIMEOUT", + &[ + ("owner", &repo.owner), + ("name", &repo.name), + ("timeout", "60") + ], + Some("checkNetwork"), + )) + })??; // 根据 skills_path 确定源目录路径 let source = if let Some(ref skills_path) = repo.skills_path { @@ -458,10 +498,11 @@ impl SkillService { if !source.exists() { let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow::anyhow!( - "技能目录不存在: {}", - source.display() - )); + return Err(anyhow::anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); } // 删除旧版本 diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index eed361e..21c5fd3 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManager } from "./RepoManager"; import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills"; +import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { onClose?: () => void; @@ -27,9 +28,22 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) { afterLoad(data); } } catch (error) { - toast.error(t("skills.loadFailed"), { - description: error instanceof Error ? error.message : t("common.error"), + const errorMessage = + error instanceof Error ? error.message : String(error); + + // 传入 "skills.loadFailed" 作为标题 + const { title, description } = formatSkillError( + errorMessage, + t, + "skills.loadFailed" + ); + + toast.error(title, { + description, + duration: 8000, }); + + console.error("Load skills failed:", error); } finally { setLoading(false); } @@ -54,8 +68,26 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) { toast.success(t("skills.installSuccess", { name: directory })); await loadSkills(); } catch (error) { - toast.error(t("skills.installFailed"), { - description: error instanceof Error ? error.message : t("common.error"), + const errorMessage = + error instanceof Error ? error.message : String(error); + + // 使用错误解析器格式化错误,传入 "skills.installFailed" + const { title, description } = formatSkillError( + errorMessage, + t, + "skills.installFailed" + ); + + toast.error(title, { + description, + duration: 10000, // 延长显示时间让用户看清 + }); + + // 打印到控制台方便调试 + console.error("Install skill failed:", { + directory, + error, + message: errorMessage, }); } }; @@ -66,8 +98,25 @@ export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) { toast.success(t("skills.uninstallSuccess", { name: directory })); await loadSkills(); } catch (error) { - toast.error(t("skills.uninstallFailed"), { - description: error instanceof Error ? error.message : t("common.error"), + const errorMessage = + error instanceof Error ? error.message : String(error); + + // 使用错误解析器格式化错误,传入 "skills.uninstallFailed" + const { title, description } = formatSkillError( + errorMessage, + t, + "skills.uninstallFailed" + ); + + toast.error(title, { + description, + duration: 10000, + }); + + console.error("Uninstall skill failed:", { + directory, + error, + message: errorMessage, }); } }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f92f297..536692e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -675,6 +675,34 @@ "installFailed": "Failed to install", "uninstallSuccess": "Skill {{name}} uninstalled", "uninstallFailed": "Failed to uninstall", + "error": { + "skillNotFound": "Skill not found: {{directory}}", + "missingRepoInfo": "Missing repository info (owner or name)", + "downloadTimeout": "Download repository {{owner}}/{{name}} timeout ({{timeout}}s)", + "downloadTimeoutHint": "Please check network connection or retry later", + "skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}", + "skillDirNotFound": "Skill directory not found: {{path}}", + "emptyArchive": "Downloaded archive is empty", + "downloadFailed": "Download failed: HTTP {{status}}", + "allBranchesFailed": "All branches failed, tried: {{branches}}", + "httpError": "HTTP error {{status}}", + "http403": "GitHub access restricted, possibly rate limited", + "http404": "Repository or branch not found, please check URL", + "http429": "Too many requests, please wait and retry", + "parseMetadataFailed": "Failed to parse skill metadata", + "getHomeDirFailed": "Unable to get user home directory", + "networkError": "Network error", + "fsError": "File system error", + "unknownError": "Unknown error", + "suggestion": { + "checkNetwork": "Please check network connection", + "checkProxy": "Consider configuring HTTP proxy", + "retryLater": "Please retry later", + "checkRepoUrl": "Please check repository URL and branch name", + "checkDiskSpace": "Please check disk space", + "checkPermission": "Please check directory permissions" + } + }, "repo": { "title": "Manage Skill Repositories", "description": "Add or remove GitHub skill repository sources", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1e135a6..190a58e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -675,6 +675,34 @@ "installFailed": "安装失败", "uninstallSuccess": "技能 {{name}} 已卸载", "uninstallFailed": "卸载失败", + "error": { + "skillNotFound": "技能不存在:{{directory}}", + "missingRepoInfo": "缺少仓库信息(owner 或 name)", + "downloadTimeout": "下载仓库 {{owner}}/{{name}} 超时({{timeout}}秒)", + "downloadTimeoutHint": "请检查网络连接或稍后重试", + "skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'", + "skillDirNotFound": "技能目录不存在:{{path}}", + "emptyArchive": "下载的压缩包为空", + "downloadFailed": "下载失败:HTTP {{status}}", + "allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}", + "httpError": "HTTP 错误 {{status}}", + "http403": "GitHub 访问受限,可能是请求频率过高", + "http404": "仓库或分支不存在,请检查地址", + "http429": "请求过于频繁,请等待后重试", + "parseMetadataFailed": "解析技能元数据失败", + "getHomeDirFailed": "无法获取用户主目录", + "networkError": "网络错误", + "fsError": "文件系统错误", + "unknownError": "未知错误", + "suggestion": { + "checkNetwork": "请检查网络连接", + "checkProxy": "建议配置 HTTP 代理", + "retryLater": "请稍后重试", + "checkRepoUrl": "请检查仓库地址和分支名称", + "checkDiskSpace": "请检查磁盘空间", + "checkPermission": "请检查目录权限" + } + }, "repo": { "title": "管理技能仓库", "description": "添加或删除 GitHub 技能仓库源", diff --git a/src/lib/errors/skillErrorParser.ts b/src/lib/errors/skillErrorParser.ts new file mode 100644 index 0000000..929611a --- /dev/null +++ b/src/lib/errors/skillErrorParser.ts @@ -0,0 +1,104 @@ +import { TFunction } from "i18next"; + +/** + * 结构化错误对象 + */ +export interface SkillError { + code: string; + context: Record; + suggestion?: string; +} + +/** + * 尝试解析后端返回的错误字符串 + * 如果是 JSON 格式,返回结构化错误;否则返回 null + */ +export function parseSkillError(errorString: string): SkillError | null { + try { + const parsed = JSON.parse(errorString); + if (parsed.code && parsed.context) { + return parsed as SkillError; + } + } catch { + // 不是 JSON 格式,返回 null + } + return null; +} + +/** + * 将错误码映射到 i18n key + */ +function getErrorI18nKey(code: string): string { + const mapping: Record = { + SKILL_NOT_FOUND: "skills.error.skillNotFound", + MISSING_REPO_INFO: "skills.error.missingRepoInfo", + DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout", + DOWNLOAD_FAILED: "skills.error.downloadFailed", + SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound", + EMPTY_ARCHIVE: "skills.error.emptyArchive", + GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed", + }; + + return mapping[code] || "skills.error.unknownError"; +} + +/** + * 将建议码映射到 i18n key + */ +function getSuggestionI18nKey(suggestion: string): string { + const mapping: Record = { + checkNetwork: "skills.error.suggestion.checkNetwork", + checkProxy: "skills.error.suggestion.checkProxy", + retryLater: "skills.error.suggestion.retryLater", + checkRepoUrl: "skills.error.suggestion.checkRepoUrl", + checkPermission: "skills.error.suggestion.checkPermission", + http403: "skills.error.http403", + http404: "skills.error.http404", + http429: "skills.error.http429", + }; + + return mapping[suggestion] || suggestion; +} + +/** + * 格式化技能错误为用户友好的消息 + * @param errorString 后端返回的错误字符串 + * @param t i18next 翻译函数 + * @param defaultTitle 默认标题的 i18n key(如 "skills.installFailed") + * @returns 包含标题和描述的对象 + */ +export function formatSkillError( + errorString: string, + t: TFunction, + defaultTitle: string = "skills.installFailed" +): { title: string; description: string } { + const parsedError = parseSkillError(errorString); + + if (!parsedError) { + // 如果不是结构化错误,返回原始错误字符串 + return { + title: t(defaultTitle), + description: errorString || t("common.error"), + }; + } + + const { code, context, suggestion } = parsedError; + + // 获取错误消息的 i18n key + const errorKey = getErrorI18nKey(code); + + // 构建描述(错误消息 + 建议) + let description = t(errorKey, context); + + // 如果有建议,追加到描述中 + if (suggestion) { + const suggestionKey = getSuggestionI18nKey(suggestion); + const suggestionText = t(suggestionKey); + description += `\n\n${suggestionText}`; + } + + return { + title: t(defaultTitle), + description, + }; +}