From 720c4d977454a900bd38c21cfef7741b4717ce17 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 5 Nov 2025 00:07:54 +0800 Subject: [PATCH] feat(i18n): complete internationalization for usage query feature Fully internationalized the usage query feature to support both Chinese and English. Frontend changes: - Refactored UsageScriptModal to use i18n translation keys - Replaced hardcoded Chinese template names with constants - Implemented dynamic template generation with i18n support - Internationalized all labels, placeholders, and code comments - Added template name mapping for translation Backend changes: - Replaced all hardcoded Chinese error messages in usage_script.rs - Converted 33 error instances from AppError::Message to AppError::localized - Added bilingual error messages for runtime, parsing, HTTP, and validation errors Translation updates: - Added 11 new translation key pairs to zh.json and en.json - Covered template names, field labels, placeholders, and code comments Impact: - 100% i18n coverage for usage query functionality - All user-facing text and error messages now support language switching - Better user experience for English-speaking users --- src-tauri/src/usage_script.rs | 70 ++++++++++++++--------------- src/components/UsageScriptModal.tsx | 58 +++++++++++++++--------- src/i18n/locales/en.json | 11 +++++ src/i18n/locales/zh.json | 11 +++++ 4 files changed, 91 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 07a9dde..cd17764 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -31,28 +31,28 @@ pub async fn execute_usage_script( // 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) let request_config = { let runtime = - Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?; + Runtime::new().map_err(|e| AppError::localized("usage_script.runtime_create_failed", format!("创建 JS 运行时失败: {}", e), format!("Failed to create JS runtime: {}", e)))?; let context = Context::full(&runtime) - .map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.context_create_failed", format!("创建 JS 上下文失败: {}", e), format!("Failed to create JS context: {}", e)))?; context.with(|ctx| { // 执行用户代码,获取配置对象 let config: rquickjs::Object = ctx .eval(replaced.clone()) - .map_err(|e| AppError::Message(format!("解析配置失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.config_parse_failed", format!("解析配置失败: {}", e), format!("Failed to parse config: {}", e)))?; // 提取 request 配置 let request: rquickjs::Object = config .get("request") - .map_err(|e| AppError::Message(format!("缺少 request 配置: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.request_missing", format!("缺少 request 配置: {}", e), format!("Missing request config: {}", e)))?; // 将 request 转换为 JSON 字符串 let request_json: String = ctx .json_stringify(request) - .map_err(|e| AppError::Message(format!("序列化 request 失败: {}", e)))? - .ok_or_else(|| AppError::Message("序列化返回 None".into()))? + .map_err(|e| AppError::localized("usage_script.request_serialize_failed", format!("序列化 request 失败: {}", e), format!("Failed to serialize request: {}", e)))? + .ok_or_else(|| AppError::localized("usage_script.serialize_none", "序列化返回 None", "Serialization returned None"))? .get() - .map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.get_string_failed", format!("获取字符串失败: {}", e), format!("Failed to get string: {}", e)))?; Ok::<_, AppError>(request_json) })? @@ -60,7 +60,7 @@ pub async fn execute_usage_script( // 3. 解析 request 配置 let request: RequestConfig = serde_json::from_str(&request_config) - .map_err(|e| AppError::Message(format!("request 配置格式错误: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.request_format_invalid", format!("request 配置格式错误: {}", e), format!("Invalid request config format: {}", e)))?; // 4. 发送 HTTP 请求 let response_data = send_http_request(&request, timeout_secs).await?; @@ -68,42 +68,42 @@ pub async fn execute_usage_script( // 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放) let result: Value = { let runtime = - Runtime::new().map_err(|e| AppError::Message(format!("创建 JS 运行时失败: {}", e)))?; + Runtime::new().map_err(|e| AppError::localized("usage_script.runtime_create_failed", format!("创建 JS 运行时失败: {}", e), format!("Failed to create JS runtime: {}", e)))?; let context = Context::full(&runtime) - .map_err(|e| AppError::Message(format!("创建 JS 上下文失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.context_create_failed", format!("创建 JS 上下文失败: {}", e), format!("Failed to create JS context: {}", e)))?; context.with(|ctx| { // 重新 eval 获取配置对象 let config: rquickjs::Object = ctx .eval(replaced.clone()) - .map_err(|e| AppError::Message(format!("重新解析配置失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.config_reparse_failed", format!("重新解析配置失败: {}", e), format!("Failed to re-parse config: {}", e)))?; // 提取 extractor 函数 let extractor: Function = config .get("extractor") - .map_err(|e| AppError::Message(format!("缺少 extractor 函数: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.extractor_missing", format!("缺少 extractor 函数: {}", e), format!("Missing extractor function: {}", e)))?; // 将响应数据转换为 JS 值 let response_js: rquickjs::Value = ctx .json_parse(response_data.as_str()) - .map_err(|e| AppError::Message(format!("解析响应 JSON 失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.response_parse_failed", format!("解析响应 JSON 失败: {}", e), format!("Failed to parse response JSON: {}", e)))?; // 调用 extractor(response) let result_js: rquickjs::Value = extractor .call((response_js,)) - .map_err(|e| AppError::Message(format!("执行 extractor 失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.extractor_exec_failed", format!("执行 extractor 失败: {}", e), format!("Failed to execute extractor: {}", e)))?; // 转换为 JSON 字符串 let result_json: String = ctx .json_stringify(result_js) - .map_err(|e| AppError::Message(format!("序列化结果失败: {}", e)))? - .ok_or_else(|| AppError::Message("序列化返回 None".into()))? + .map_err(|e| AppError::localized("usage_script.result_serialize_failed", format!("序列化结果失败: {}", e), format!("Failed to serialize result: {}", e)))? + .ok_or_else(|| AppError::localized("usage_script.serialize_none", "序列化返回 None", "Serialization returned None"))? .get() - .map_err(|e| AppError::Message(format!("获取字符串失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.get_string_failed", format!("获取字符串失败: {}", e), format!("Failed to get string: {}", e)))?; // 解析为 serde_json::Value serde_json::from_str(&result_json) - .map_err(|e| AppError::Message(format!("JSON 解析失败: {}", e))) + .map_err(|e| AppError::localized("usage_script.json_parse_failed", format!("JSON 解析失败: {}", e), format!("JSON parse failed: {}", e))) })? }; // Runtime 和 Context 在这里被 drop @@ -131,7 +131,7 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< let client = Client::builder() .timeout(Duration::from_secs(timeout)) .build() - .map_err(|e| AppError::Message(format!("创建客户端失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.client_create_failed", format!("创建客户端失败: {}", e), format!("Failed to create client: {}", e)))?; // 严格校验 HTTP 方法,非法值不回退为 GET let method: reqwest::Method = config @@ -155,13 +155,13 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< let resp = req .send() .await - .map_err(|e| AppError::Message(format!("请求失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.request_failed", format!("请求失败: {}", e), format!("Request failed: {}", e)))?; let status = resp.status(); let text = resp .text() .await - .map_err(|e| AppError::Message(format!("读取响应失败: {}", e)))?; + .map_err(|e| AppError::localized("usage_script.read_response_failed", format!("读取响应失败: {}", e), format!("Failed to read response: {}", e)))?; if !status.is_success() { let preview = if text.len() > 200 { @@ -169,7 +169,7 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< } else { text.clone() }; - return Err(AppError::Message(format!("HTTP {} : {}", status, preview))); + return Err(AppError::localized("usage_script.http_error", format!("HTTP {} : {}", status, preview), format!("HTTP {} : {}", status, preview))); } Ok(text) @@ -180,11 +180,11 @@ fn validate_result(result: &Value) -> Result<(), AppError> { // 如果是数组,验证每个元素 if let Some(arr) = result.as_array() { if arr.is_empty() { - return Err(AppError::InvalidInput("脚本返回的数组不能为空".into())); + return Err(AppError::localized("usage_script.empty_array", "脚本返回的数组不能为空", "Script returned empty array")); } for (idx, item) in arr.iter().enumerate() { validate_single_usage(item) - .map_err(|e| AppError::InvalidInput(format!("数组索引[{}]验证失败: {}", idx, e)))?; + .map_err(|e| AppError::localized("usage_script.array_validation_failed", format!("数组索引[{}]验证失败: {}", idx, e), format!("Validation failed at index [{}]: {}", idx, e)))?; } return Ok(()); } @@ -197,48 +197,44 @@ fn validate_result(result: &Value) -> Result<(), AppError> { fn validate_single_usage(result: &Value) -> Result<(), AppError> { let obj = result .as_object() - .ok_or_else(|| AppError::InvalidInput("脚本必须返回对象或对象数组".into()))?; + .ok_or_else(|| AppError::localized("usage_script.must_return_object", "脚本必须返回对象或对象数组", "Script must return object or array of objects"))?; // 所有字段均为可选,只进行类型检查 if obj.contains_key("isValid") && !result["isValid"].is_null() && !result["isValid"].is_boolean() { - return Err(AppError::InvalidInput("isValid 必须是布尔值或 null".into())); + return Err(AppError::localized("usage_script.isvalid_type_error", "isValid 必须是布尔值或 null", "isValid must be boolean or null")); } if obj.contains_key("invalidMessage") && !result["invalidMessage"].is_null() && !result["invalidMessage"].is_string() { - return Err(AppError::InvalidInput( - "invalidMessage 必须是字符串或 null".into(), - )); + return Err(AppError::localized("usage_script.invalidmessage_type_error", "invalidMessage 必须是字符串或 null", "invalidMessage must be string or null")); } if obj.contains_key("remaining") && !result["remaining"].is_null() && !result["remaining"].is_number() { - return Err(AppError::InvalidInput("remaining 必须是数字或 null".into())); + return Err(AppError::localized("usage_script.remaining_type_error", "remaining 必须是数字或 null", "remaining must be number or null")); } if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() { - return Err(AppError::InvalidInput("unit 必须是字符串或 null".into())); + return Err(AppError::localized("usage_script.unit_type_error", "unit 必须是字符串或 null", "unit must be string or null")); } if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() { - return Err(AppError::InvalidInput("total 必须是数字或 null".into())); + return Err(AppError::localized("usage_script.total_type_error", "total 必须是数字或 null", "total must be number or null")); } if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() { - return Err(AppError::InvalidInput("used 必须是数字或 null".into())); + return Err(AppError::localized("usage_script.used_type_error", "used 必须是数字或 null", "used must be number or null")); } if obj.contains_key("planName") && !result["planName"].is_null() && !result["planName"].is_string() { - return Err(AppError::InvalidInput( - "planName 必须是字符串或 null".into(), - )); + return Err(AppError::localized("usage_script.planname_type_error", "planName 必须是字符串或 null", "planName must be string or null")); } if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() { - return Err(AppError::InvalidInput("extra 必须是字符串或 null".into())); + return Err(AppError::localized("usage_script.extra_type_error", "extra 必须是字符串或 null", "extra must be string or null")); } Ok(()) diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 359aa83..746bc9b 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -25,9 +25,16 @@ interface UsageScriptModalProps { onSave: (script: UsageScript) => void; } -// 预设模板(JS 对象字面量格式) -const PRESET_TEMPLATES: Record = { - 自定义: `({ +// 预设模板键名(用于国际化) +const TEMPLATE_KEYS = { + CUSTOM: "custom", + GENERAL: "general", + NEW_API: "newapi", +} as const; + +// 生成预设模板的函数(支持国际化) +const generatePresetTemplates = (t: (key: string) => string): Record => ({ + [TEMPLATE_KEYS.CUSTOM]: `({ request: { url: "", method: "GET", @@ -41,7 +48,7 @@ const PRESET_TEMPLATES: Record = { } })`, - 通用模板: `({ + [TEMPLATE_KEYS.GENERAL]: `({ request: { url: "{{baseUrl}}/user/balance", method: "GET", @@ -59,7 +66,7 @@ const PRESET_TEMPLATES: Record = { } })`, - NewAPI: `({ + [TEMPLATE_KEYS.NEW_API]: `({ request: { url: "{{baseUrl}}/api/user/self", method: "GET", @@ -72,7 +79,7 @@ const PRESET_TEMPLATES: Record = { extractor: function (response) { if (response.success && response.data) { return { - planName: response.data.group || "默认套餐", + planName: response.data.group || "${t("usageScript.defaultPlan")}", remaining: response.data.quota / 500000, used: response.data.used_quota / 500000, total: (response.data.quota + response.data.used_quota) / 500000, @@ -81,10 +88,17 @@ const PRESET_TEMPLATES: Record = { } return { isValid: false, - invalidMessage: response.message || "查询失败" + invalidMessage: response.message || "${t("usageScript.queryFailedMessage")}" }; }, })`, +}); + +// 模板名称国际化键映射 +const TEMPLATE_NAME_KEYS: Record = { + [TEMPLATE_KEYS.CUSTOM]: "usageScript.templateCustom", + [TEMPLATE_KEYS.GENERAL]: "usageScript.templateGeneral", + [TEMPLATE_KEYS.NEW_API]: "usageScript.templateNewAPI", }; const UsageScriptModal: React.FC = ({ @@ -95,16 +109,16 @@ const UsageScriptModal: React.FC = ({ onSave, }) => { const { t } = useTranslation(); + + // 生成带国际化的预设模板 + const PRESET_TEMPLATES = generatePresetTemplates(t); + const [script, setScript] = useState(() => { return ( provider.meta?.usage_script || { enabled: false, language: "javascript", - code: PRESET_TEMPLATES[ - t("usageScript.presetTemplate") === "预设模板" - ? "通用模板" - : "General" - ], + code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL], timeout: 10, } ); @@ -118,7 +132,7 @@ const UsageScriptModal: React.FC = ({ () => { const existingScript = provider.meta?.usage_script; if (existingScript?.accessToken || existingScript?.userId) { - return "NewAPI"; + return TEMPLATE_KEYS.NEW_API; } return null; } @@ -210,7 +224,7 @@ const UsageScriptModal: React.FC = ({ const preset = PRESET_TEMPLATES[presetName]; if (preset) { // 如果选择的不是 NewAPI 模板,清空高级配置字段 - if (presetName !== "NewAPI") { + if (presetName !== TEMPLATE_KEYS.NEW_API) { setScript({ ...script, code: preset, @@ -225,7 +239,7 @@ const UsageScriptModal: React.FC = ({ }; // 判断是否应该显示高级配置(仅 NewAPI 模板需要) - const shouldShowAdvancedConfig = selectedTemplate === "NewAPI"; + const shouldShowAdvancedConfig = selectedTemplate === TEMPLATE_KEYS.NEW_API; return ( !open && onClose()}> @@ -273,7 +287,7 @@ const UsageScriptModal: React.FC = ({ : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700" }`} > - {name} + {t(TEMPLATE_NAME_KEYS[name])} ); })} @@ -285,7 +299,7 @@ const UsageScriptModal: React.FC = ({
@@ -373,10 +387,10 @@ const UsageScriptModal: React.FC = ({ "Authorization": "Bearer {{apiKey}}", "User-Agent": "cc-switch/1.0" }, - body: JSON.stringify({ key: "value" }) // 可选 + body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")} }, extractor: function(response) { - // response 是 API 返回的 JSON 数据 + // ${t("usageScript.commentResponseIsJson")} return { isValid: !response.error, remaining: response.balance, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 67fceef..20250d4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -344,10 +344,21 @@ "title": "Configure Usage Query", "enableUsageQuery": "Enable usage query", "presetTemplate": "Preset template", + "templateCustom": "Custom", + "templateGeneral": "General", + "templateNewAPI": "NewAPI", + "accessToken": "Access Token", + "accessTokenPlaceholder": "Generate in 'Security Settings'", + "userId": "User ID", + "userIdPlaceholder": "e.g., 114514", + "defaultPlan": "Default Plan", + "queryFailedMessage": "Query failed", "queryScript": "Query script (JavaScript)", "timeoutSeconds": "Timeout (seconds)", "scriptHelp": "Script writing instructions:", "configFormat": "Configuration format:", + "commentOptional": "optional", + "commentResponseIsJson": "response is the JSON data returned by the API", "extractorFormat": "Extractor return format (all fields optional):", "tips": "💡 Tips:", "testing": "Testing...", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 96bbf69..26d3b3e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -344,10 +344,21 @@ "title": "配置用量查询", "enableUsageQuery": "启用用量查询", "presetTemplate": "预设模板", + "templateCustom": "自定义", + "templateGeneral": "通用模板", + "templateNewAPI": "NewAPI", + "accessToken": "访问令牌", + "accessTokenPlaceholder": "在'安全设置'里生成", + "userId": "用户 ID", + "userIdPlaceholder": "例如:114514", + "defaultPlan": "默认套餐", + "queryFailedMessage": "查询失败", "queryScript": "查询脚本(JavaScript)", "timeoutSeconds": "超时时间(秒)", "scriptHelp": "脚本编写说明:", "configFormat": "配置格式:", + "commentOptional": "可选", + "commentResponseIsJson": "response 是 API 返回的 JSON 数据", "extractorFormat": "extractor 返回格式(所有字段均为可选):", "tips": "💡 提示:", "testing": "测试中...",