diff --git a/README_i18n.md b/README_i18n.md new file mode 100644 index 0000000..1caf9b9 --- /dev/null +++ b/README_i18n.md @@ -0,0 +1,76 @@ +# CC Switch 国际化功能说明 + +## 已完成的工作 + +1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包 +2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件 +3. **翻译文件**:创建了英文和中文翻译文件 +4. **组件更新**:替换了主要组件中的硬编码文案 +5. **语言切换器**:添加了语言切换按钮 + +## 文件结构 + +``` +src/ +├── i18n/ +│ ├── index.ts # 国际化配置文件 +│ └── locales/ +│ ├── en.json # 英文翻译 +│ └── zh.json # 中文翻译 +├── components/ +│ └── LanguageSwitcher.tsx # 语言切换组件 +└── main.tsx # 导入国际化配置 +``` + +## 默认语言设置 + +- **默认语言**:英文 (en) +- **回退语言**:英文 (en) + +## 使用方式 + +1. 在组件中导入 `useTranslation`: + ```tsx + import { useTranslation } from 'react-i18next'; + + function MyComponent() { + const { t } = useTranslation(); + return
{t('common.save')}
; + } + ``` + +2. 切换语言: + ```tsx + const { i18n } = useTranslation(); + i18n.changeLanguage('zh'); // 切换到中文 + ``` + +## 翻译键结构 + +- `common.*` - 通用文案(保存、取消、设置等) +- `header.*` - 头部相关文案 +- `provider.*` - 供应商相关文案 +- `notifications.*` - 通知消息 +- `settings.*` - 设置页面文案 +- `apps.*` - 应用名称 +- `console.*` - 控制台日志信息 + +## 测试功能 + +应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。 + +## 已更新的组件 + +- ✅ App.tsx - 主应用组件 +- ✅ ConfirmDialog.tsx - 确认对话框 +- ✅ AddProviderModal.tsx - 添加供应商弹窗 +- ✅ EditProviderModal.tsx - 编辑供应商弹窗 +- ✅ ProviderList.tsx - 供应商列表 +- ✅ LanguageSwitcher.tsx - 语言切换器 +- 🔄 SettingsModal.tsx - 设置弹窗(部分完成) + +## 注意事项 + +1. 所有新的文案都应该添加到翻译文件中,而不是硬编码 +2. 翻译键名应该有意义且结构化 +3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言 diff --git a/package.json b/package.json index edc4df1..ae5b4af 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,12 @@ "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", "codemirror": "^6.0.2", + "i18next": "^25.5.2", "jsonc-parser": "^3.2.1", "lucide-react": "^0.542.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^16.0.0", "tailwindcss": "^4.1.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3393607..7c2b881 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + i18next: + specifier: ^25.5.2 + version: 25.5.2(typescript@5.9.2) jsonc-parser: specifier: ^3.2.1 version: 3.3.1 @@ -53,6 +56,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^16.0.0 + version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) tailwindcss: specifier: ^4.1.13 version: 4.1.13 @@ -159,6 +165,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -750,6 +760,17 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@25.5.2: + resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + jiti@2.5.1: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true @@ -890,6 +911,22 @@ packages: peerDependencies: react: ^18.3.1 + react-i18next@16.0.0: + resolution: {integrity: sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==} + peerDependencies: + i18next: '>= 25.5.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -973,6 +1010,10 @@ packages: terser: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -1079,6 +1120,8 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1591,6 +1634,16 @@ snapshots: graceful-fs@4.2.11: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@25.5.2(typescript@5.9.2): + dependencies: + '@babel/runtime': 7.28.4 + optionalDependencies: + typescript: 5.9.2 + jiti@2.5.1: {} js-tokens@4.0.0: {} @@ -1692,6 +1745,16 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-i18next@16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2): + dependencies: + '@babel/runtime': 7.28.4 + html-parse-stringify: 3.0.1 + i18next: 25.5.2(typescript@5.9.2) + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.2 + react-refresh@0.17.0: {} react@18.3.1: @@ -1767,6 +1830,8 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 + void-elements@3.1.0: {} + w3c-keyname@2.2.8: {} yallist@3.1.1: {} diff --git a/src/App.tsx b/src/App.tsx index 3fd7737..f709f3d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { Provider } from "./types"; import { AppType } from "./lib/tauri-api"; import ProviderList from "./components/ProviderList"; @@ -8,6 +9,7 @@ import { ConfirmDialog } from "./components/ConfirmDialog"; import { AppSwitcher } from "./components/AppSwitcher"; import SettingsModal from "./components/SettingsModal"; import { UpdateBadge } from "./components/UpdateBadge"; +import LanguageSwitcher from "./components/LanguageSwitcher"; import { Plus, Settings, Moon, Sun } from "lucide-react"; import { buttonStyles } from "./lib/styles"; import { useDarkMode } from "./hooks/useDarkMode"; @@ -17,6 +19,7 @@ import { getCodexBaseUrl } from "./utils/providerConfigUtils"; import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync"; function App() { + const { t } = useTranslation(); const { isDarkMode, toggleDarkMode } = useDarkMode(); const { isAutoSyncEnabled } = useVSCodeAutoSync(); const [activeApp, setActiveApp] = useState("claude"); @@ -24,7 +27,7 @@ function App() { const [currentProviderId, setCurrentProviderId] = useState(""); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [editingProviderId, setEditingProviderId] = useState( - null, + null ); const [notification, setNotification] = useState<{ message: string; @@ -44,7 +47,7 @@ function App() { const showNotification = ( message: string, type: "success" | "error", - duration = 3000, + duration = 3000 ) => { // 清除之前的定时器 if (timeoutRef.current) { @@ -88,7 +91,7 @@ function App() { try { unlisten = await window.api.onProviderSwitched(async (data) => { if (import.meta.env.DEV) { - console.log("收到供应商切换事件:", data); + console.log(t("console.providerSwitchReceived"), data); } // 如果当前应用类型匹配,则重新加载数据 @@ -102,7 +105,7 @@ function App() { } }); } catch (error) { - console.error("设置供应商切换监听器失败:", error); + console.error(t("console.setupListenerFailed"), error); } }; @@ -152,16 +155,16 @@ function App() { await loadProviders(); setEditingProviderId(null); // 显示编辑成功提示 - showNotification("供应商配置已保存", "success", 2000); + showNotification(t("notifications.providerSaved"), "success", 2000); // 更新托盘菜单 await window.api.updateTrayMenu(); } catch (error) { - console.error("更新供应商失败:", error); + console.error(t("console.updateProviderFailed"), error); setEditingProviderId(null); const errorMessage = extractErrorMessage(error); const message = errorMessage - ? `保存失败:${errorMessage}` - : "保存失败,请重试"; + ? t("notifications.saveFailed", { error: errorMessage }) + : t("notifications.saveFailedGeneric"); showNotification(message, "error", errorMessage ? 6000 : 3000); } }; @@ -170,13 +173,13 @@ function App() { const provider = providers[id]; setConfirmDialog({ isOpen: true, - title: "删除供应商", - message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`, + title: t("confirm.deleteProvider"), + message: t("confirm.deleteProviderMessage", { name: provider?.name }), onConfirm: async () => { await window.api.deleteProvider(id, activeApp); await loadProviders(); setConfirmDialog(null); - showNotification("供应商删除成功", "success"); + showNotification(t("notifications.providerDeleted"), "success"); // 更新托盘菜单 await window.api.updateTrayMenu(); }, @@ -190,9 +193,9 @@ function App() { if (!status.exists) { if (!silent) { showNotification( - "未找到 VS Code 用户设置文件 (settings.json)", + t("notifications.vscodeSettingsNotFound"), "error", - 3000, + 3000 ); } return; @@ -208,11 +211,7 @@ function App() { const parsed = getCodexBaseUrl(provider); if (!parsed) { if (!silent) { - showNotification( - "当前配置缺少 base_url,无法写入 VS Code", - "error", - 4000, - ); + showNotification(t("notifications.missingBaseUrl"), "error", 4000); } return; } @@ -226,16 +225,17 @@ function App() { if (updatedSettings !== raw) { await window.api.writeVSCodeSettings(updatedSettings); if (!silent) { - showNotification("已同步到 VS Code", "success", 1500); + showNotification(t("notifications.syncedToVSCode"), "success", 1500); } } // 触发providers重新加载,以更新VS Code按钮状态 await loadProviders(); } catch (error: any) { - console.error("同步到VS Code失败:", error); + console.error(t("console.syncToVSCodeFailed"), error); if (!silent) { - const errorMessage = error?.message || "同步 VS Code 失败"; + const errorMessage = + error?.message || t("notifications.syncVSCodeFailed"); showNotification(errorMessage, "error", 5000); } } @@ -246,11 +246,11 @@ function App() { if (success) { setCurrentProviderId(id); // 显示重启提示 - const appName = activeApp === "claude" ? "Claude Code" : "Codex"; + const appName = t(`apps.${activeApp}`); showNotification( - `切换成功!请重启 ${appName} 终端以生效`, + t("notifications.switchSuccess", { appName }), "success", - 2000, + 2000 ); // 更新托盘菜单 await window.api.updateTrayMenu(); @@ -260,7 +260,7 @@ function App() { await syncCodexToVSCode(id, true); // silent模式,不显示通知 } } else { - showNotification("切换失败,请检查配置", "error"); + showNotification(t("notifications.switchFailed"), "error"); } }; @@ -271,13 +271,13 @@ function App() { if (result.success) { await loadProviders(); - showNotification("已从现有配置创建默认供应商", "success", 3000); + showNotification(t("notifications.autoImported"), "success", 3000); // 更新托盘菜单 await window.api.updateTrayMenu(); } // 如果导入失败(比如没有现有配置),静默处理,不显示错误 } catch (error) { - console.error("自动导入默认配置失败:", error); + console.error(t("console.autoImportFailed"), error); // 静默处理,不影响用户体验 } }; @@ -293,22 +293,27 @@ function App() { target="_blank" rel="noopener noreferrer" className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors" - title="在 GitHub 上查看" + title={t("header.viewOnGithub")} > CC Switch +
@@ -324,7 +329,7 @@ function App() { className={`inline-flex items-center gap-2 ${buttonStyles.primary}`} > - 添加供应商 + {t("header.addProvider")}
diff --git a/src/components/AddProviderModal.tsx b/src/components/AddProviderModal.tsx index 17bebb1..8d92753 100644 --- a/src/components/AddProviderModal.tsx +++ b/src/components/AddProviderModal.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Provider } from "../types"; import { AppType } from "../lib/tauri-api"; import ProviderForm from "./ProviderForm"; @@ -14,11 +15,13 @@ const AddProviderModal: React.FC = ({ onAdd, onClose, }) => { + const { t } = useTranslation(); + return ( = ({ isOpen, title, message, - confirmText = "确定", - cancelText = "取消", + confirmText, + cancelText, onConfirm, onCancel, }) => { + const { t } = useTranslation(); + if (!isOpen) return null; return ( @@ -65,13 +68,13 @@ export const ConfirmDialog: React.FC = ({ className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors" autoFocus > - {cancelText} + {cancelText || t("common.cancel")} diff --git a/src/components/EditProviderModal.tsx b/src/components/EditProviderModal.tsx index 5c3d4ef..3844443 100644 --- a/src/components/EditProviderModal.tsx +++ b/src/components/EditProviderModal.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Provider } from "../types"; import { AppType } from "../lib/tauri-api"; import ProviderForm from "./ProviderForm"; @@ -16,6 +17,8 @@ const EditProviderModal: React.FC = ({ onSave, onClose, }) => { + const { t } = useTranslation(); + const handleSubmit = (data: Omit) => { onSave({ ...provider, @@ -26,8 +29,8 @@ const EditProviderModal: React.FC = ({ return ( { + const { i18n } = useTranslation(); + + const toggleLanguage = () => { + const newLang = i18n.language === "en" ? "zh" : "en"; + i18n.changeLanguage(newLang); + }; + + return ( + + ); +}; + +export default LanguageSwitcher; diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 785e7f1..fd725b0 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Provider } from "../types"; import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react"; import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; @@ -35,6 +36,7 @@ const ProviderList: React.FC = ({ appType, onNotify, }) => { + const { t } = useTranslation(); // 提取API地址(兼容不同供应商配置:Claude env / Codex TOML) const getApiUrl = (provider: Provider): string => { try { @@ -49,9 +51,9 @@ const ProviderList: React.FC = ({ const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/); if (match && match[2]) return match[2]; } - return "未配置官网地址"; + return t("provider.notConfigured"); } catch { - return "配置错误"; + return t("provider.configError"); } }; @@ -59,7 +61,7 @@ const ProviderList: React.FC = ({ try { await window.api.openExternal(url); } catch (error) { - console.error("打开链接失败:", error); + console.error(t("console.openLinkFailed"), error); } }; @@ -106,11 +108,7 @@ const ProviderList: React.FC = ({ try { const status = await window.api.getVSCodeSettingsStatus(); if (!status.exists) { - onNotify?.( - "未找到 VS Code 用户设置文件 (settings.json)", - "error", - 3000 - ); + onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000); return; } @@ -121,7 +119,7 @@ const ProviderList: React.FC = ({ if (!isOfficial) { const parsed = getCodexBaseUrl(provider); if (!parsed) { - onNotify?.("当前配置缺少 base_url,无法写入 VS Code", "error", 4000); + onNotify?.(t("notifications.missingBaseUrl"), "error", 4000); return; } } @@ -131,7 +129,7 @@ const ProviderList: React.FC = ({ if (next === raw) { // 幂等:没有变化也提示成功 - onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000); + onNotify?.(t("notifications.appliedToVSCode"), "success", 3000); setVscodeAppliedFor(provider.id); // 用户手动应用时,启用自动同步 enableAutoSync(); @@ -139,13 +137,14 @@ const ProviderList: React.FC = ({ } await window.api.writeVSCodeSettings(next); - onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000); + onNotify?.(t("notifications.appliedToVSCode"), "success", 3000); setVscodeAppliedFor(provider.id); // 用户手动应用时,启用自动同步 enableAutoSync(); } catch (e: any) { console.error(e); - const msg = e && e.message ? e.message : "应用到 VS Code 失败"; + const msg = + e && e.message ? e.message : t("notifications.syncVSCodeFailed"); onNotify?.(msg, "error", 5000); } }; @@ -154,11 +153,7 @@ const ProviderList: React.FC = ({ try { const status = await window.api.getVSCodeSettingsStatus(); if (!status.exists) { - onNotify?.( - "未找到 VS Code 用户设置文件 (settings.json)", - "error", - 3000 - ); + onNotify?.(t("notifications.vscodeSettingsNotFound"), "error", 3000); return; } const raw = await window.api.readVSCodeSettings(); @@ -167,20 +162,21 @@ const ProviderList: React.FC = ({ isOfficial: true, }); if (next === raw) { - onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000); + onNotify?.(t("notifications.removedFromVSCode"), "success", 3000); setVscodeAppliedFor(null); // 用户手动移除时,禁用自动同步 disableAutoSync(); return; } await window.api.writeVSCodeSettings(next); - onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000); + onNotify?.(t("notifications.removedFromVSCode"), "success", 3000); setVscodeAppliedFor(null); // 用户手动移除时,禁用自动同步 disableAutoSync(); } catch (e: any) { console.error(e); - const msg = e && e.message ? e.message : "移除失败"; + const msg = + e && e.message ? e.message : t("notifications.syncVSCodeFailed"); onNotify?.(msg, "error", 5000); } }; @@ -214,10 +210,10 @@ const ProviderList: React.FC = ({

- 还没有添加任何供应商 + {t("provider.noProviders")}

- 点击右上角的"添加供应商"按钮开始配置您的第一个API供应商 + {t("provider.noProvidersDescription")}

) : ( @@ -247,7 +243,7 @@ const ProviderList: React.FC = ({ )} > - 当前使用 + {t("provider.currentlyUsing")} @@ -292,13 +288,13 @@ const ProviderList: React.FC = ({ )} title={ vscodeAppliedFor === provider.id - ? "从 VS Code 移除我们写入的配置" - : "将当前供应商应用到 VS Code" + ? t("provider.removeFromVSCode") + : t("provider.applyToVSCode") } > {vscodeAppliedFor === provider.id - ? "从 VS Code 移除" - : "应用到 VS Code"} + ? t("provider.removeFromVSCode") + : t("provider.applyToVSCode")} )} @@ -332,7 +328,7 @@ const ProviderList: React.FC = ({ ? "text-gray-400 cursor-not-allowed" : "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10" )} - title="删除供应商" + title={t("provider.deleteProvider")} > diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 87c06a8..54be860 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { X, RefreshCw, @@ -24,6 +25,7 @@ interface SettingsModalProps { } export default function SettingsModal({ onClose }: SettingsModalProps) { + const { t } = useTranslation(); const [settings, setSettings] = useState({ showInTray: true, minimizeToTrayOnClose: true, @@ -54,9 +56,9 @@ export default function SettingsModal({ onClose }: SettingsModalProps) { const appVersion = await getVersion(); setVersion(appVersion); } catch (error) { - console.error("获取版本信息失败:", error); + console.error(t("console.getVersionFailed"), error); // 失败时不硬编码版本号,显示为未知 - setVersion("未知"); + setVersion(t("common.unknown")); } }; @@ -300,7 +302,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) { {/* 标题栏 */}

- 设置 + {t("settings.title")}