diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index b329a1f..3d715e5 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -1,36 +1,58 @@ import { createI18n, Composer } from 'vue-i18n'; -// 导入语言文件 -import enMessages from './locales/en.json'; -import zhMessages from './locales/zh.json'; +// 动态导入 locales 目录下的所有 .json 文件 +// 使用 { eager: true } 确保同步加载,因为 i18n 实例需要在应用初始化时就绪 +// 使用 { import: 'default' } 直接获取模块的默认导出 +const localeModules = import.meta.glob('./locales/*.json', { eager: true, import: 'default' }); -// 类型推断 (可选,但推荐) -type MessageSchema = typeof enMessages; // 假设 en.json 包含所有 key +// 构建 messages 对象和可用语言列表 +const messages: Record = {}; +const availableLocales: string[] = []; -// 定义默认语言 -export const defaultLng = 'en'; +for (const path in localeModules) { + // 从路径中提取语言代码 (例如 './locales/en.json' -> 'en') + const locale = path.match(/.\/locales\/(.+)\.json$/)?.[1]; + if (locale) { + messages[locale] = localeModules[path]; // 获取导入的 JSON 内容 + availableLocales.push(locale); + } +} + +// 检查是否成功加载了任何语言文件 +if (availableLocales.length === 0) { + console.error("[i18n] No language files found in './locales/'. Please ensure language files exist and the path is correct."); +} + +// 类型推断 (基于第一个加载的语言文件,假设所有文件结构一致) +// 如果没有加载到文件,则使用空对象作为 fallback,避免运行时错误 +type MessageSchema = typeof messages[availableLocales[0]] | {}; + +// 定义默认语言 (优先使用 'en',如果不存在则使用第一个找到的语言) +export const defaultLng = availableLocales.includes('en') ? 'en' : availableLocales[0] || 'en'; // 添加 'en' 作为最终 fallback const localStorageKey = 'user-locale'; // 尝试从 localStorage 获取语言,否则回退 -const getInitialLocaleFromStorage = (): 'en' | 'zh' => { +const getInitialLocale = (): string => { const storedLocale = localStorage.getItem(localStorageKey); - if (storedLocale === 'en' || storedLocale === 'zh') { + // 检查存储的 locale 是否在当前可用的语言列表中 + if (storedLocale && availableLocales.includes(storedLocale)) { return storedLocale; } - // Fallback logic (e.g., browser language or default) + // 回退逻辑:检查浏览器语言是否可用 const navigatorLang = navigator.language?.split('-')[0]; - return navigatorLang === 'zh' ? 'zh' : defaultLng; + if (navigatorLang && availableLocales.includes(navigatorLang)) { + return navigatorLang; + } + // 最后回退到默认语言 + return defaultLng; }; -const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({ +const i18n = createI18n<[MessageSchema], string>({ // 使用 string 作为 locale 类型,因为它是动态的 legacy: false, // 必须设置为 false 才能在 Composition API 中使用 useI18n - locale: getInitialLocaleFromStorage(), // 使用从 localStorage 或回退获取的初始语言 + locale: getInitialLocale(), // 使用计算得到的初始语言 fallbackLocale: defaultLng, // 如果当前语言缺少某个 key,则回退到默认语言 - messages: { - en: enMessages, - zh: zhMessages, - }, + messages: messages, // 使用动态构建的 messages 对象 // 可选:关闭控制台的 i18n 警告 (例如缺少 key 的警告) // silentTranslationWarn: true, // silentFallbackWarn: true, @@ -38,29 +60,32 @@ const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({ /** * 设置 i18n 实例的区域设置 - * @param lang 要设置的语言代码 ('en', 'zh', etc.) + * @param lang 要设置的语言代码 */ -export const setLocale = (lang: 'en' | 'zh') => { - console.log(`[i18n] Attempting to set locale to: ${lang}`); // <-- 添加日志 - const globalComposer = i18n.global as unknown as Composer; // 强制类型断言 - if (globalComposer.availableLocales.includes(lang)) { - const currentLocale = globalComposer.locale.value; // <-- 获取当前 locale - if (currentLocale !== lang) { // <-- 仅在 locale 实际改变时更新 - globalComposer.locale.value = lang; // 访问 .value 属性 - console.log(`[i18n] Successfully updated global locale from "${currentLocale}" to "${lang}".`); // <-- 修改日志 +export const setLocale = (lang: string) => { + console.log(`[i18n] Attempting to set locale to: ${lang}`); + const globalComposer = i18n.global as unknown as Composer; + // 使用动态获取的可用语言列表进行检查 + if (availableLocales.includes(lang)) { + const currentLocale = globalComposer.locale.value; + if (currentLocale !== lang) { + globalComposer.locale.value = lang; // 更新 locale + console.log(`[i18n] Successfully updated global locale from "${currentLocale}" to "${lang}".`); try { localStorage.setItem(localStorageKey, lang); // 持久化到 localStorage - console.log(`[i18n] Locale "${lang}" saved to localStorage.`); // <-- 修改日志 + console.log(`[i18n] Locale "${lang}" saved to localStorage.`); } catch (e) { console.error('[i18n] Failed to save locale to localStorage:', e); } } else { - console.log(`[i18n] Locale is already "${lang}". No update needed.`); // <-- 添加日志 + console.log(`[i18n] Locale is already "${lang}". No update needed.`); } } else { - console.warn(`[i18n] Locale "${lang}" is not available. Available locales: ${globalComposer.availableLocales.join(', ')}`); // <-- 修改日志 + console.warn(`[i18n] Locale "${lang}" is not available. Available locales: ${availableLocales.join(', ')}`); } }; +// 导出可用语言列表,方便其他地方使用 (例如语言选择器) +export { availableLocales }; export default i18n; diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 0f7e509..7ca5160 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -628,6 +628,20 @@ "error": { "saveFailed": "Failed to save CAPTCHA settings." } + }, + "commandInputSync": { + "title": "Command Input Sync", + "selectLabel": "Sync Target:", + "targetNone": "None", + "targetQuickCommands": "Quick Commands", + "targetCommandHistory": "Command History", + "description": "Sync the content of the command input bar to the search box of the selected panel in real-time.", + "success": { + "saved": "Sync target saved." + }, + "error": { + "saveFailed": "Failed to save sync target." + } } }, "common": { diff --git a/packages/frontend/src/locales/jp.json b/packages/frontend/src/locales/jp.json new file mode 100644 index 0000000..28b00f7 --- /dev/null +++ b/packages/frontend/src/locales/jp.json @@ -0,0 +1,901 @@ +{ + "appName": "星枢ターミナル", + "projectName": "星枢ターミナル", + "slogan": "星垂平野闊,枢動万端通", + "nav": { + "dashboard": "ダッシュボード", + "terminal": "ターミナル", + "proxies": "プロキシ管理", + "login": "ログイン", + "logout": "ログアウト", + "notifications": "通知管理", + "auditLogs": "監査ログ", + "settings": "設定", + "customizeStyle": "外観のカスタマイズ" + }, + "styleCustomizer": { + "title": "外観のカスタマイズ", + "uiStyles": "UI スタイル", + "terminalStyles": "ターミナルスタイル", + "backgroundSettings": "背景設定", + "uiDescription": "アプリケーションの UI の色、フォントなどを調整します。", + "resetUiTheme": "UI テーマをリセット", + "saveUiTheme": "UI テーマを保存", + "terminalFontFamily": "ターミナルフォント", + "terminalFontPlaceholder": "例: \"Fira Code\", Consolas, monospace", + "terminalFontDescription": "フォント名を入力し、カンマで区切ります。フォント名にスペースが含まれる場合は、引用符で囲んでください。", + "terminalThemeSelection": "ターミナルテーマ", + "activeTheme": "現在のテーマ", + "addNewTheme": "新しいテーマを作成", + "importTheme": "テーマをインポート", + "editThemeTitle": "ターミナルテーマの編集", + "newThemeTitle": "ターミナルテーマの作成", + "newThemeDefaultName": "新しいテーマ", + "themeName": "テーマ名", + "errorThemeNameRequired": "テーマ名を入力してください。", + "themeUpdatedSuccess": "テーマの更新に成功しました。", + "themeCreatedSuccess": "テーマの作成に成功しました。", + "themeSaveFailed": "テーマの保存に失敗しました。", + "themeDeletedSuccess": "テーマの削除に成功しました。", + "themeDeleteFailed": "テーマの削除に失敗しました: {message}", + "importSuccess": "テーマのインポートに成功しました。", + "importFailed": "テーマのインポートに失敗しました。", + "exportFailed": "テーマのエクスポートに失敗しました: {message}", + "pageBackground": "ページの背景", + "terminalBackground": "ターミナルの背景", + "noBackground": "背景なし", + "uploadPageBg": "ページの背景をアップロード", + "removePageBg": "ページの背景を削除", + "uploadTerminalBg": "ターミナルの背景をアップロード", + "removeTerminalBg": "ターミナルの背景を削除", + "uploadFailed": "アップロードに失敗しました: {message}", + "pageBgUploadSuccess": "ページの背景のアップロードに成功しました。", + "terminalBgUploadSuccess": "ターミナルの背景のアップロードに成功しました。", + "pageBgRemoved": "ページの背景を削除しました。", + "terminalBgRemoved": "ターミナルの背景を削除しました。", + "removeBgFailed": "背景の削除に失敗しました: {message}", + "uiThemeSaveFailed": "UI テーマの保存に失敗しました: {message}", + "uiThemeReset": "UI テーマをデフォルトにリセットしました。", + "uiThemeResetFailed": "UI テーマのリセットに失敗しました: {message}", + "terminalFontSaved": "ターミナルフォントを保存しました。", + "terminalFontSaveFailed": "ターミナルフォントの保存に失敗しました: {message}", + "setActiveThemeFailed": "アクティブなターミナルテーマの設定に失敗しました: {message}", + "terminalFontSize": "ターミナルフォントサイズ", + "errorInvalidFontSize": "無効なフォントサイズです。正数を入力してください。", + "terminalFontSizeSaved": "ターミナルフォントサイズを保存しました。", + "terminalFontSizeSaveFailed": "ターミナルフォントサイズの保存に失敗しました: {message}", + "otherSettings": "その他の設定", + "editorFontSize": "エディターフォントサイズ", + "editorFontSizeSaved": "エディターフォントサイズを保存しました。", + "editorFontSizeSaveFailed": "エディターフォントサイズの保存に失敗しました: {message}", + "errorInvalidEditorFontSize": "無効なフォントサイズです。正数を入力してください。", + "uiThemeJsonEditorTitle": "UI テーマ JSON エディター", + "uiThemeJsonEditorDesc": "JSON を使用して UI テーマ設定を直接編集します。ここで変更を加えてテキスト領域からフォーカスを外すと、上記のカラーピッカーが同期的に更新されます。", + "errorInvalidJsonObject": "無効な入力です。有効な JSON オブジェクトを入力してください。", + "errorInvalidJsonConfig": "無効な JSON 設定", + "editAsCopy": "コピーとして編集", + "cannotDeletePreset": "プリセットテーマは削除できません", + "applyThemeTooltip": "このテーマを適用", + "terminalThemeJsonEditorTitle": "ターミナルテーマ JSON エディター", + "terminalThemeJsonEditorDesc": "JSON を使用してターミナルテーマ設定を直接編集します。ここで変更を加えてテキスト領域からフォーカスを外すと、下のカラーピッカーが同期的に更新されます。", + "terminalThemeColorEditorTitle": "ターミナルテーマカラーエディター", + "errorFixJsonBeforeSave": "保存する前に JSON フォーマットのエラーを修正してください。", + "applyButton": "適用", + "searchThemePlaceholder": "テーマ名を検索...", + "exportActiveThemeTooltip": "現在アクティブなテーマを JSON ファイルとしてエクスポート", + "exportActiveTheme": "現在のテーマをエクスポート", + "themeModeLabel": "テーマモード:", + "defaultMode": "デフォルトモード", + "darkMode": "ダークモード", + "darkModeApplied": "ダークモードが適用されました", + "darkModeApplyFailed": "ダークモードの適用に失敗しました: {message}" + }, + "login": { + "title": "ユーザーログイン", + "username": "ユーザー名", + "password": "パスワード", + "loginButton": "ログイン", + "loggingIn": "ログイン中...", + "twoFactorPrompt": "2段階認証コードを入力してください:", + "verifyButton": "検証", + "rememberMe": "ログイン状態を保持", + "captchaPrompt": "以下の認証を完了してください:", + "error": { + "captchaLoadFailed": "CAPTCHA の読み込みに失敗しました。ページをリロードしてください。", + "captchaRequired": "CAPTCHA を完了してください。" + }, + "recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。" + }, + "connections": { + "addConnection": "新しい接続を追加", + "noConnections": "接続がありません。'新しい接続を追加'をクリックして作成してください。", + "addFirstConnection": "最初の接続を追加", + "table": { + "name": "名前", + "host": "ホスト", + "port": "ポート", + "user": "ユーザー名", + "authMethod": "認証方法", + "tags": "タグ", + "lastConnected": "最終接続", + "actions": "アクション" + }, + "actions": { + "connect": "接続", + "edit": "編集", + "delete": "削除", + "test": "テスト", + "testing": "テスト中..." + }, + "form": { + "title": "新しい接続を追加", + "name": "名前:", + "host": "ホスト/IP:", + "port": "ポート:", + "username": "ユーザー名:", + "authMethod": "認証方法:", + "authMethodPassword": "パスワード", + "authMethodKey": "SSHキー", + "password": "パスワード:", + "privateKey": "秘密鍵:", + "passphrase": "パスフレーズ:", + "optional": "オプション", + "confirm": "追加", + "adding": "追加中...", + "cancel": "キャンセル", + "errorRequiredFields": "すべての必須フィールドを入力してください。", + "errorPasswordRequired": "パスワード認証を使用する場合は、パスワードは必須です。", + "errorPrivateKeyRequired": "キー認証を使用する場合は、秘密鍵は必須です。", + "errorPasswordRequiredOnSwitch": "パスワード認証に切り替える場合は、パスワードは必須です。", + "errorPrivateKeyRequiredOnSwitch": "キー認証に切り替える場合は、秘密鍵は必須です。", + "errorPort": "ポート番号は 1 から 65535 の間である必要があります。", + "errorAdd": "接続の追加に失敗しました: {error}", + "titleEdit": "接続の編集", + "confirmEdit": "編集を確定", + "saving": "保存中...", + "errorUpdate": "接続の更新に失敗しました: {error}", + "keyUpdateNote": "秘密鍵とパスフレーズを空のままにして、既存のキーを保持します。", + "proxy": "プロキシ:", + "noProxy": "プロキシなし", + "tags": "タグ:", + "sectionBasic": "基本情報", + "sectionAuth": "認証情報", + "sectionAdvanced": "詳細設定", + "testConnection": "接続をテスト", + "testing": "テスト中..." + }, + "test": { + "success": "接続テストに成功しました!", + "failed": "接続テストに失敗しました: {error}", + "latencyTooltip": "このレイテンシは、TCP接続、プロキシネゴシエーション、SSHハンドシェイク、認証などのステップを含む、新しいSSH接続の確立にかかる時間を測定します。通常、確立された接続上のインタラクションのレイテンシよりも高くなります。", + "errorMissingFields": "ホスト、ポート、ユーザー名を入力し、認証方法を選択してください。", + "errorUnknown": "テスト中に不明なエラーが発生しました。", + "errorNetwork": "ネットワークエラーまたはサーバーにアクセスできません。", + "testingInProgress": "テスト実行中...", + "errorPrefix": "エラー:" + }, + "prompts": { + "confirmDelete": "\"{name}\"接続を削除しますか?この操作は元に戻せません。" + }, + "errors": { + "deleteFailed": "接続の削除に失敗しました: {error}" + }, + "status": { + "never": "なし" + }, + "untaggedGroup": "タグなし", + "noUntaggedConnections": "タグなしの接続はありません。" + }, + "proxies": { + "title": "プロキシ管理", + "addProxy": "新しいプロキシを追加", + "loading": "プロキシをロード中...", + "error": "プロキシリストのロードに失敗しました: {error}", + "noProxies": "プロキシがありません。'新しいプロキシを追加'をクリックして作成してください。", + "actions": { + "edit": "編集", + "delete": "削除" + }, + "form": { + "title": "新しいプロキシを追加", + "titleEdit": "プロキシの編集", + "name": "名前:", + "type": "タイプ:", + "host": "ホスト/IP:", + "port": "ポート:", + "username": "ユーザー名:", + "password": "パスワード:", + "optional": "オプション", + "confirm": "追加", + "confirmEdit": "編集を確定", + "adding": "追加中...", + "saving": "保存中...", + "cancel": "キャンセル", + "errorRequiredFields": "すべての必須フィールドを入力してください。", + "errorPort": "ポート番号は 1 から 65535 の間である必要があります。", + "errorAdd": "プロキシの追加に失敗しました: {error}", + "errorUpdate": "プロキシの更新に失敗しました: {error}", + "passwordUpdateNote": "パスワードを空のままにして、既存のパスワードを保持します。" + }, + "prompts": { + "confirmDelete": "\"{name}\"プロキシを削除しますか?この操作は元に戻せません。" + }, + "errors": { + "deleteFailed": "プロキシの削除に失敗しました: {error}" + } + }, + "workspace": { + "terminal": { + "reconnectingMsg": "再接続を試行中..." + } + }, + "fileManager": { + "currentPath": "現在のパス", + "loading": "ディレクトリをロード中...", + "emptyDirectory": "ディレクトリは空です", + "uploadTasks": "アップロードタスク", + "actions": { + "refresh": "更新", + "parentDirectory": "上位ディレクトリ", + "uploadFile": "ファイルをアップロード", + "upload": "アップロード", + "newFolder": "新しいフォルダー", + "newFile": "新しいファイル", + "rename": "名前を変更", + "changePermissions": "権限を変更", + "delete": "削除", + "deleteMultiple": "{count} 個の項目を削除", + "download": "ダウンロード", + "cancel": "キャンセル", + "save": "保存", + "closeTab": "タブを閉じる", + "closeEditor": "エディターを閉じる" + }, + "headers": { + "type": "タイプ", + "name": "名前", + "size": "サイズ", + "permissions": "権限", + "modified": "変更日" + }, + "uploadStatus": { + "cancelled": "キャンセルされました" + }, + "errors": { + "generic": "エラー", + "missingConnectionId": "現在の接続 ID を取得できません", + "createFolderFailed": "フォルダーの作成に失敗しました", + "deleteFailed": "削除に失敗しました", + "renameFailed": "名前の変更に失敗しました", + "chmodFailed": "権限の変更に失敗しました", + "invalidPermissionsFormat": "無効な権限形式です。3 桁または 4 桁の 8 進数を入力してください (例: 755 または 0755)。", + "readFileError": "ファイルの読み取り中にエラーが発生しました", + "readFileFailed": "ファイルの読み取りに失敗しました", + "fileDecodeError": "ファイルのデコードに失敗しました (UTF-8 エンコーディングではない可能性があります)", + "saveFailed": "ファイルの保存に失敗しました", + "saveTimeout": "保存がタイムアウトしました", + "fileExists": "ファイル \"{name}\" はすでに存在します。", + "loadDirectoryFailed": "ディレクトリの読み込みに失敗しました" + }, + "prompts": { + "enterFolderName": "新しいフォルダーの名前を入力してください:", + "confirmOverwrite": "ファイル \"{name}\" はすでに存在します。上書きしますか?", + "confirmDeleteMultiple": "選択した {count} 個の項目を削除しますか?この操作は元に戻せません。", + "confirmDeleteFolder": "フォルダー \"{name}\" とそのすべての内容を削除しますか?この操作は元に戻せません。", + "confirmDeleteFile": "ファイル \"{name}\" を削除しますか?この操作は元に戻せません。", + "enterNewName": "\"{oldName}\" の新しい名前を入力してください:", + "enterNewPermissions": "\"{name}\" の新しい権限を入力してください (8 進数, 例: 755):", + "enterFileName": "新しいファイルの名前を入力してください:" + }, + "editingFile": "編集中", + "loadingFile": "ファイルをロード中...", + "saving": "保存中", + "saveSuccess": "保存に成功しました", + "saveError": "保存中にエラーが発生しました", + "editPathTooltip": "パスをクリックして編集", + "noOpenFile": "ファイルが開いていません", + "selectFileToEdit": "ファイルマネージャーから編集するファイルを選択してください。", + "searchPlaceholder": "ファイルを検索..." + }, + "statusMonitor": { + "title": "サーバー状態", + "errorPrefix": "エラー:", + "loading": "データを待機中...", + "cpuModelLabel": "CPU モデル:", + "osLabel": "OS:", + "cpuLabel": "CPU:", + "memoryLabel": "メモリ:", + "swapLabel": "スワップ:", + "diskLabel": "ディスク:", + "networkLabel": "ネットワーク", + "notAvailable": "N/A", + "bytesPerSecond": "B/秒", + "kiloBytesPerSecond": "KB/秒", + "megaBytesPerSecond": "MB/秒", + "gigaBytesPerSecond": "GB/秒", + "megaBytes": "MB", + "gigaBytes": "GB", + "swapNotAvailable": "スワップは利用できません" + }, + "tags": { + "title": "タグ管理", + "addTag": "新しいタグを追加", + "loading": "タグをロード中...", + "error": "タグリストのロードに失敗しました: {error}", + "noTags": "タグがありません。'新しいタグを追加'をクリックして作成してください。", + "prompts": { + "confirmDelete": "\"{name}\"タグを削除しますか?この操作は元に戻せません。" + }, + "inputPlaceholder": "検索またはタグを作成...", + "removeSelection": "このタグの選択を解除", + "deleteTagGlobally": "このタグをグローバルに削除" + }, + "settings": { + "title": "設定", + "category": { + "security": "セキュリティ設定", + "appearance": "外観設定", + "system": "システム設定" + }, + "changePassword": { + "title": "パスワードを変更", + "currentPassword": "現在のパスワード:", + "newPassword": "新しいパスワード:", + "confirmPassword": "新しいパスワードを再入力:", + "submit": "変更を確定", + "success": "パスワードの変更に成功しました!", + "error": { + "passwordsDoNotMatch": "新しいパスワードと確認用パスワードが一致しません。", + "generic": "パスワードの変更に失敗しました。後でもう一度お試しください。" + } + }, + "twoFactor": { + "title": "2段階認証 (TOTP)", + "status": { + "enabled": "2段階認証は有効です。", + "disabled": "2段階認証は現在無効です。" + }, + "enable": { + "button": "2段階認証を有効にする" + }, + "setup": { + "scanQrCode": "Authenticator アプリを使用して、以下の QR コードをスキャンしてください:", + "orEnterSecret": "または、シークレットキーを手動で入力してください:", + "enterCode": "アプリで生成された 6 桁のコードを入力してください:", + "verifyButton": "確認して有効にする" + }, + "disable": { + "button": "2段階認証を無効にする", + "passwordPrompt": "現在のログインパスワードを入力して、無効にすることを確認してください:" + }, + "success": { + "activated": "2段階認証が有効になりました!", + "disabled": "2段階認証が無効になりました。" + }, + "error": { + "setupFailed": "2段階認証の設定情報の取得に失敗しました。", + "codeRequired": "コードを入力してください。", + "verificationFailed": "無効なコードまたは期限切れのコードです。", + "passwordRequiredForDisable": "無効にするには、現在のパスワードを入力する必要があります。", + "disableFailed": "2段階認証の無効化に失敗しました。" + } + }, + "ipWhitelist": { + "title": "IP ホワイトリスト", + "description": "このアプリケーションへのアクセスを許可する IP アドレスまたは範囲を設定します。空白のままにすると、すべての IP が許可されます。", + "label": "許可された IP アドレス/範囲 (1 行に 1 つまたはカンマ区切り):", + "hint": "IPv4, IPv6 および CIDR をサポート (例: 192.168.1.100, 10.0.0.0/8, 2001:db8::/32)。", + "saveButton": "ホワイトリストを保存", + "success": { + "saved": "IP ホワイトリストを保存しました。" + }, + "error": { + "saveFailed": "IP ホワイトリストの保存に失敗しました。" + } + }, + "popupEditor": { + "title": "ポップアップファイルエディター", + "enableLabel": "ファイルを開くときにポップアップエディターを表示する", + "saveButton": "設定を保存", + "success": { + "saved": "ポップアップエディターの設定を保存しました。" + }, + "error": { + "saveFailed": "ポップアップエディターの設定の保存に失敗しました。" + } + }, + "shareEditorTabs": { + "title": "エディタータブ", + "enableLabel": "すべてのセッションでエディタータブを共有する", + "description": "有効にすると、すべての SSH セッションが同じ開いているファイルエディタータブのセットを共有します。無効にすると、各セッションには独自の独立したタブのセットがあります。", + "saveButton": "設定を保存", + "success": { + "saved": "エディタータブの共有設定を保存しました。" + }, + "error": { + "saveFailed": "エディタータブの共有設定の保存に失敗しました。" + } + }, + "language": { + "title": "言語設定", + "selectLabel": "インターフェース言語:", + "saveButton": "言語を保存", + "success": { + "saved": "言語設定を保存しました。" + }, + "error": { + "saveFailed": "言語設定の保存に失敗しました。" + } + }, + "passkey": { + "title": "Passkey 設定", + "description": "Passkey (生体認証またはセキュリティキー) を使用してパスワードなし認証を行い、アカウントのセキュリティとログインの利便性を向上させます。", + "nameLabel": "Passkey 名", + "namePlaceholder": "例: マイノートパソコン", + "registerButton": "新しい Passkey を登録", + "error": { + "nameRequired": "Passkey 名を入力してください。", + "cancelled": "Passkey の登録がキャンセルされました。", + "genericRegistration": "Passkey を登録できません: {message}", + "verificationFailed": "登録に失敗しました: {message}" + }, + "success": { + "registered": "Passkey の登録に成功しました!" + } + }, + "notifications": { + "title": "通知設定", + "addChannel": "通知チャンネルを追加", + "noChannels": "通知チャンネルが設定されていません。", + "triggers": "トリガーイベント", + "noEventsEnabled": "有効なイベントはありません", + "confirmDelete": "通知チャンネル\"{name}\"を削除しますか?この操作は元に戻せません。", + "types": { + "webhook": "Webhook", + "email": "メール", + "telegram": "Telegram" + }, + "form": { + "addTitle": "通知チャンネルの追加", + "editTitle": "通知チャンネルの編集", + "name": "チャンネル名:", + "channelType": "チャンネルタイプ:", + "channelTypeEditNote": "作成後はチャンネルタイプを変更できません。", + "webhookMethod": "HTTP メソッド:", + "webhookHeaders": "カスタムヘッダー", + "webhookBodyTemplate": "リクエスト本文テンプレート (オプション)", + "webhookBodyPlaceholder": "デフォルト: JSON フォーマットのペイロード。利用可能:", + "emailTo": "宛先メールアドレス:", + "emailToHelp": "複数のメールアドレスをカンマで区切ります。", + "emailSubjectTemplate": "メールの件名テンプレート (オプション)", + "emailSubjectPlaceholder": "デフォルト: 通知:", + "smtpHost": "SMTP ホスト:", + "smtpPort": "SMTP ポート:", + "smtpSecure": "TLS/SSL を使用", + "smtpUser": "SMTP ユーザー名:", + "smtpPass": "SMTP パスワード:", + "smtpFrom": "送信元メールアドレス:", + "smtpFromHelp": "'From' フィールドで使用されるアドレス。", + "testButton": "テスト通知", + "testSuccess": "テスト通知の送信に成功しました!", + "testFailed": "テスト通知の送信に失敗しました", + "fillRequiredToTest": "テストを有効にするには、必須フィールドを入力してください。", + "telegramToken": "ボットトークン:", + "telegramTokenHelp": "安全に保管してください。環境変数の使用をお勧めします。", + "telegramChatId": "チャット ID:", + "telegramMessageTemplate": "メッセージテンプレート (オプション)", + "telegramMessagePlaceholder": "デフォルト: Markdown 形式。利用可能:", + "enabledEvents": "有効なイベント:", + "templateHelp": "利用可能なプレースホルダー:", + "invalidJson": "無効な JSON 形式" + }, + "events": { + "LOGIN_SUCCESS": "ログイン成功", + "LOGIN_FAILURE": "ログイン失敗", + "LOGOUT": "ログアウト", + "PASSWORD_CHANGED": "パスワード変更", + "2FA_ENABLED": "2段階認証有効", + "2FA_DISABLED": "2段階認証無効", + "PASSKEY_REGISTERED": "Passkey 登録", + "PASSKEY_DELETED": "Passkey 削除", + "CONNECTION_CREATED": "接続作成", + "CONNECTION_UPDATED": "接続更新", + "CONNECTION_DELETED": "接続削除", + "CONNECTION_TESTED": "接続テスト", + "CONNECTIONS_IMPORTED": "接続インポート", + "CONNECTIONS_EXPORTED": "接続エクスポート", + "PROXY_CREATED": "プロキシ作成", + "PROXY_UPDATED": "プロキシ更新", + "PROXY_DELETED": "プロキシ削除", + "TAG_CREATED": "タグ作成", + "TAG_UPDATED": "タグ更新", + "TAG_DELETED": "タグ削除", + "SETTINGS_UPDATED": "設定更新", + "IP_WHITELIST_UPDATED": "IP ホワイトリスト更新", + "NOTIFICATION_SETTING_CREATED": "通知設定作成", + "NOTIFICATION_SETTING_UPDATED": "通知設定更新", + "NOTIFICATION_SETTING_DELETED": "通知設定削除", + "SFTP_ACTION": "SFTP 操作", + "SSH_CONNECT_SUCCESS": "SSH 接続成功", + "SSH_CONNECT_FAILURE": "SSH 接続失敗", + "SSH_SHELL_FAILURE": "SSH Shell オープン失敗", + "SERVER_STARTED": "サーバー起動", + "SERVER_ERROR": "サーバーエラー", + "DATABASE_MIGRATION": "データベース移行", + "ADMIN_SETUP_COMPLETE": "初期管理者設定完了" + } + }, + "appearance": { + "title": "外観設定", + "description": "アプリケーションのビジュアルテーマと背景をカスタマイズします。", + "customizeButton": "外観をカスタマイズ" + }, + "autoCopyOnSelect": { + "title": "ターミナル自動コピー", + "enableLabel": "マウスボタンを離したときに選択したテキストを自動的にコピーする", + "saveButton": "保存", + "success": { + "saved": "自動コピーの設定を保存しました。" + }, + "error": { + "saveFailed": "自動コピーの設定の保存に失敗しました。" + } + }, + "docker": { + "title": "Docker マネージャー設定", + "refreshIntervalLabel": "ステータス更新間隔 (秒):", + "refreshIntervalHint": "Docker コンテナのステータスと統計情報を取得する頻度 (最小値は 1)。", + "defaultExpandLabel": "デフォルトでコンテナの詳細を展開", + "saveButton": "Docker 設定を保存", + "success": { + "saved": "Docker 設定を保存しました。" + }, + "error": { + "saveFailed": "Docker 設定の保存に失敗しました。", + "invalidInterval": "更新間隔は正の整数である必要があります。" + } + }, + "statusMonitor": { + "title": "ステータスモニター設定", + "refreshIntervalLabel": "ステータス更新間隔 (秒):", + "refreshIntervalHint": "サーバーの CPU、メモリ、ディスクなどのステータスを取得する頻度 (最小値は 1)。", + "saveButton": "ステータスモニター設定を保存", + "success": { + "saved": "ステータスモニター設定を保存しました。" + }, + "error": { + "saveFailed": "ステータスモニター設定の保存に失敗しました。", + "invalidInterval": "更新間隔は正の整数である必要があります。" + } + }, + "workspace": { + "title": "ワークスペースとターミナル", + "sidebarPersistentTitle": "サイドバーの動作", + "sidebarPersistentLabel": "ポップアップ後にサイドバーを固定 (自動的に折りたたまない)", + "sidebarPersistentDescription": "有効にすると、サイドバーの外側をクリックしてもサイドバーは自動的に折りたたまれません。", + "success": { + "sidebarPersistentSaved": "サイドバーの設定を保存しました。" + }, + "error": { + "sidebarPersistentSaveFailed": "サイドバーの設定の保存に失敗しました。" + } + }, + "ipBlacklist": { + "title": "IP ブラックリスト管理", + "description": "ログイン試行回数制限と自動禁止時間を設定します。ローカルアドレス (127.0.0.1, ::1) は禁止されません。", + "maxAttemptsLabel": "最大試行回数:", + "banDurationLabel": "禁止時間 (秒):", + "saveConfigButton": "構成を保存", + "currentBannedTitle": "現在禁止されている IP アドレス", + "loadingList": "ブラックリストを読み込み中...", + "noBannedIps": "ブラックリストに IP アドレスはありません。", + "confirmRemoveIp": "IP アドレス \"{ip}\" をブラックリストから削除しますか?", + "table": { + "ipAddress": "IP アドレス", + "attempts": "試行回数", + "lastAttempt": "最終試行時間", + "bannedUntil": "禁止終了時間", + "actions": "アクション", + "removeButton": "削除", + "deleting": "削除中..." + }, + "success": { + "configUpdated": "ブラックリスト構成を保存しました。" + }, + "error": { + "fetchFailed": "ブラックリストの取得に失敗しました", + "deleteFailed": "削除に失敗しました", + "invalidMaxAttempts": "最大試行回数は正の整数である必要があります。", + "invalidBanDuration": "禁止時間は正の整数 (秒) である必要があります。", + "updateConfigFailed": "ブラックリスト構成の更新に失敗しました" + } + }, + "captcha": { + "title": "CAPTCHA 設定", + "description": "自動化された攻撃を防ぐために、ログインページに CAPTCHA 検証を設定します。", + "enableLabel": "ログインページで CAPTCHA を有効にする", + "providerLabel": "CAPTCHA プロバイダー:", + "providerNone": "なし (無効)", + "hcaptchaHint": "から", + "recaptchaHint": "から", + "siteKeyLabel": "サイトキー (公開):", + "secretKeyLabel": "シークレットキー (非公開):", + "secretKeyHint": "このキーは安全に保管してください。サーバーに安全に保存されます。", + "saveButton": "CAPTCHA 設定を保存", + "success": { + "saved": "CAPTCHA 設定を保存しました。" + }, + "error": { + "saveFailed": "CAPTCHA 設定の保存に失敗しました。" + } + }, + "commandInputSync": { + "title": "コマンド入力同期", + "selectLabel": "同期ターゲット:", + "targetNone": "なし", + "targetQuickCommands": "クイックコマンド", + "targetCommandHistory": "コマンド履歴", + "description": "コマンド入力バーの内容を選択したパネルの検索ボックスにリアルタイムで同期します。", + "success": { + "saved": "同期ターゲットを保存しました。" + }, + "error": { + "saveFailed": "同期ターゲットの保存に失敗しました。" + } + } +}, + "common": { + "loading": "ロード中...", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中...", + "testing": "テスト中...", + "edit": "編集", + "delete": "削除", + "enabled": "有効", + "disabled": "無効", + "settings": "設定", + "errorOccurred": "エラーが発生しました。", + "close": "閉じる", + "remove": "削除", + "expand": "展開", + "collapse": "折りたたむ", + "search": "検索", + "all": "すべて", + "filter": "フィルター" + }, + "layoutConfigurator": { + "title": "レイアウトマネージャー", + "availablePanes": "利用可能なパネル", + "layoutPreview": "メインレイアウトプレビュー (ここにドラッグ)", + "resetDefault": "デフォルトに戻す", + "noAvailablePanes": "すべてのパネルがレイアウトにあります", + "emptyLayout": "レイアウトが空です。左側からパネルをドラッグするか、コンテナーを追加してください。", + "leftSidebar": "左サイドバーパネル", + "rightSidebar": "右サイドバーパネル", + "dropHere": "利用可能なパネルからここにドラッグ", + "confirmClose": "未保存の変更があります。閉じてもよろしいですか?", + "confirmReset": "デフォルトのレイアウトとサイドバー構成に戻しますか?現在の変更は失われます。", + "saveError": "レイアウトの保存中にエラーが発生しました。後でもう一度お試しください。", + "confirmClearLayout": "レイアウト全体をクリアしますか?すべてのパネルが利用可能なリストに戻ります。" + }, + "layoutNodeEditor": { + "containerLabel": "コンテナー ({direction})", + "horizontal": "水平", + "vertical": "垂直", + "toggleDirection": "方向を切り替える", + "addHorizontalContainer": "水平コンテナーを追加", + "addVerticalContainer": "垂直コンテナーを追加", + "removeNode": "このノードを削除", + "dragHandle": "ドラッグして順序を調整または移動", + "dropHere": "パネルまたはコンテナーをここにドラッグ" + }, + "auditLog": { + "title": "監査ログ", + "searchPlaceholder": "詳細を", + "noLogs": "監査ログが見つかりませんでした。", + "table": { + "timestamp": "タイムスタンプ", + "actionType": "アクションタイプ", + "details": "詳細" + }, + "paginationInfo": "{currentPage} ページ / 全 {totalPages} ページ ({totalLogs} 件のログ)", + "actions": { + "LOGIN_SUCCESS": "ログイン成功", + "LOGIN_FAILURE": "ログイン失敗", + "LOGOUT": "ログアウト", + "PASSWORD_CHANGED": "パスワード変更", + "2FA_ENABLED": "2段階認証有効", + "2FA_DISABLED": "2段階認証無効", + "PASSKEY_REGISTERED": "Passkey 登録", + "PASSKEY_DELETED": "Passkey 削除", + "CONNECTION_CREATED": "接続作成", + "CONNECTION_UPDATED": "接続更新", + "CONNECTION_DELETED": "接続削除", + "CONNECTION_TESTED": "接続テスト", + "CONNECTIONS_IMPORTED": "接続インポート", + "CONNECTIONS_EXPORTED": "接続エクスポート", + "PROXY_CREATED": "プロキシ作成", + "PROXY_UPDATED": "プロキシ更新", + "PROXY_DELETED": "プロキシ削除", + "TAG_CREATED": "タグ作成", + "TAG_UPDATED": "タグ更新", + "TAG_DELETED": "タグ削除", + "SETTINGS_UPDATED": "設定更新", + "IP_WHITELIST_UPDATED": "IP ホワイトリスト更新", + "NOTIFICATION_SETTING_CREATED": "通知設定作成", + "NOTIFICATION_SETTING_UPDATED": "通知設定更新", + "NOTIFICATION_SETTING_DELETED": "通知設定削除", + "API_KEY_CREATED": "APIキー作成", + "API_KEY_DELETED": "APIキー削除", + "SFTP_ACTION": "SFTP 操作", + "SSH_CONNECT_SUCCESS": "SSH 接続成功", + "SSH_CONNECT_FAILURE": "SSH 接続失敗", + "SSH_SHELL_FAILURE": "SSH Shell オープン失敗", + "SERVER_STARTED": "サーバー起動", + "SERVER_ERROR": "サーバーエラー", + "DATABASE_MIGRATION": "データベース移行", + "ADMIN_SETUP_COMPLETE": "初期管理者設定完了", + "REMOTE_DESKTOP_CONNECTING": "リモートデスクトップ接続中", + "REMOTE_DESKTOP_CONNECTED": "リモートデスクトップ接続済", + "REMOTE_DESKTOP_DISCONNECTED": "リモートデスクトップ切断" + } + }, + "workspaceConnectionList": { + "untagged": "タグなし", + "searchPlaceholder": "名前またはホストを検索...", + "noResults": "\"{searchTerm}\"に一致する接続は見つかりませんでした。" + }, + "commandInputBar": { + "placeholder": "ここにコマンドを入力して Enter キーを押すと、ターミナルに送信されます...", + "searchPlaceholder": "ターミナル内で検索...", + "openSearch": "ターミナル検索を開く", + "closeSearch": "ターミナル検索を閉じる", + "findPrevious": "前を検索", + "findNext": "次を検索", + "configureFocusSwitch": "フォーカススイッチャーを設定" + }, + "layout": { + "loading": "ロード中...", + "configure": "レイアウトを設定", + "pane": { + "connections": "接続リスト", + "terminal": "ターミナル", + "commandBar": "コマンドバー", + "fileManager": "ファイルマネージャー", + "editor": "エディター", + "statusMonitor": "ステータスモニター", + "commandHistory": "コマンド履歴", + "quickCommands": "クイックコマンド", + "dockerManager": "Docker マネージャー" + }, + "noActiveSession": { + "title": "アクティブなセッションはありません", + "message": "最初にセッションに接続してください", + "fileManagerSidebar": "ファイルマネージャーにはアクティブなセッションが必要です", + "statusMonitorSidebar": "ステータスモニターにはアクティブなセッションが必要です" + } + }, + "header": { + "hide": "非表示" + }, + "commandHistory": { + "searchPlaceholder": "履歴を検索...", + "clear": "クリア", + "copy": "コピー", + "delete": "削除", + "loading": "ロード中...", + "empty": "履歴はありません", + "confirmClear": "すべての履歴をクリアしますか?", + "copied": "クリップボードにコピーしました", + "copyFailed": "コピーに失敗しました" + }, + "quickCommands": { + "searchPlaceholder": "名前またはコマンドを検索...", + "add": "追加", + "sortByName": "名前", + "sortByUsage": "使用頻度", + "usageCount": "使用回数", + "empty": "クイックコマンドはありません。'+'ボタンをクリックして作成してください!", + "confirmDelete": "クイックコマンド\"{name}\"を削除しますか?", + "form": { + "titleAdd": "クイックコマンドの追加", + "titleEdit": "クイックコマンドの編集", + "name": "名前:", + "namePlaceholder": "オプション。素早く認識するために使用", + "command": "コマンド:", + "commandPlaceholder": "例:ls -alh /home/user", + "errorCommandRequired": "コマンドは空にできません", + "add": "追加" + } + }, + "setup": { + "title": "初期設定", + "description": "最初の管理者アカウントを作成します。", + "username": "ユーザー名", + "usernamePlaceholder": "ユーザー名を入力", + "password": "パスワード", + "passwordPlaceholder": "パスワードを入力", + "confirmPassword": "パスワードを再入力", + "confirmPasswordPlaceholder": "パスワードを再入力して確認", + "submitButton": "アカウントを作成", + "settingUp": "アカウントを作成中...", + "success": "アカウントの作成に成功しました!ログインページにリダイレクトしています...", + "error": { + "passwordsDoNotMatch": "入力したパスワードが一致しません。", + "fieldsRequired": "ユーザー名とパスワードは必須です。", + "generic": "設定中にエラーが発生しました。サーバーログを確認してください。" + } + }, + "focusSwitcher": { + "configTitle": "フォーカススイッチャーの設定", + "availableInputs": "利用可能な入力ソース", + "configuredSequence": "設定されたシーケンス(ドラッグしてソート)", + "dragHere": "左側からここに入力ボックスをドラッグ", + "allInputsConfigured": "すべての利用可能な入力ソースが設定されました", + "input": { + "commandHistorySearch": "コマンド履歴検索", + "quickCommandsSearch": "クイックコマンド検索", + "fileManagerSearch": "ファイルマネージャー検索", + "commandInput": "コマンド入力", + "terminalSearch": "ターミナル内検索", + "connectionListSearch": "接続リスト検索", + "fileEditorActive": "ファイルエディター", + "fileManagerPathInput": "ファイルマネージャーパス入力" + }, + "confirmClose": "未保存の変更があります。閉じてもよろしいですか?", + "shortcutPlaceholder": "例:Alt + K", + "shortcutSettings": "ショートカット設定", + "noInputsAvailable": "設定可能な入力項目はありません" + }, + "dockerManager": { + "loading": "Dockerコンテナをロード中...", + "notAvailable": "リモートホストのDockerは利用できません", + "installHintRemote": "リモートホストにDockerがインストールされ、実行されていることを確認してください。", + "error": { + "fetchFailed": "リモートコンテナの状態の取得に失敗しました", + "commandFailed": "リモートコマンド'{command}'の実行に失敗しました", + "invalidResponse": "無効なサーバー応答を受信しました", + "noActiveSession": "アクティブなセッションはありません", + "connectFirst": "最初にセッションに接続してください", + "sshDisconnected": "SSHセッションが切断されました。", + "sshError": "SSH接続エラー", + "sshNotConnected": "SSHセッションは接続されていません。" + }, + "noContainers": "リモートホストで実行中または停止中のコンテナは見つかりませんでした。", + "header": { + "name": "名前", + "image": "イメージ", + "status": "ステータス", + "ports": "ポート", + "actions": "操作" + }, + "action": { + "restart": "再起動", + "stop": "停止", + "start": "起動", + "remove": "削除" + }, + "waitingForSsh": "SSH接続を待機中...", + "stats": { + "noData": "利用可能な統計データはありません。", + "cpu": "CPU使用率", + "memory": "メモリ使用量/制限", + "netIO": "ネットワークI/O", + "blockIO": "ブロックI/O", + "pids": "プロセス数" + } + }, + "dashboard": { + "recentConnections": "最近の接続", + "lastConnected": "最終接続:", + "noRecentConnections": "最近の接続記録はありません", + "viewAllConnections": "すべての接続を表示", + "recentActivity": "最近のアクティビティ", + "noRecentActivity": "最近のアクティビティ記録はありません", + "viewFullAuditLog": "完全な監査ログを表示" + }, + "terminalTabBar": { + "selectServerTitle": "接続するサーバーを選択" + } +} diff --git a/packages/frontend/src/stores/settings.store.ts b/packages/frontend/src/stores/settings.store.ts index 88fdd21..a155889 100644 --- a/packages/frontend/src/stores/settings.store.ts +++ b/packages/frontend/src/stores/settings.store.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia'; import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import { ref, computed } from 'vue'; // 移除 watch -import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale +import i18n, { setLocale, defaultLng, availableLocales } from '../i18n'; // Import i18n instance, setLocale, defaultLng, and availableLocales import type { PaneName } from './layout.store'; // +++ Import PaneName type +++ import { useAuthStore } from './auth.store'; // <--- 导入 authStore // Import CAPTCHA types from backend (adjust path if needed, assuming types are mirrored or shared) @@ -28,7 +28,7 @@ interface UpdateCaptchaSettingsDto { // 定义通用设置状态类型 interface SettingsState { - language?: 'en' | 'zh'; // 语言现在是可选的,因为可能在 appearance store 中处理 + language?: string; // 改为 string 以支持动态语言 ipWhitelist?: string; maxLoginAttempts?: string; loginBanDuration?: string; @@ -70,7 +70,7 @@ export const useSettingsStore = defineStore('settings', () => { async function loadInitialSettings() { isLoading.value = true; error.value = null; - let fetchedLang: 'en' | 'zh' | undefined; + let determinedLang: string | undefined; // 使用 string 类型 try { console.log('[SettingsStore] 加载通用设置...'); @@ -210,19 +210,26 @@ export const useSettingsStore = defineStore('settings', () => { // --- 语言设置 --- const langFromSettings = settings.value.language; console.log(`[SettingsStore] Language from fetched settings: ${langFromSettings}`); // <-- 添加日志 - if (langFromSettings === 'en' || langFromSettings === 'zh') { - fetchedLang = langFromSettings; + // 检查从设置加载的语言是否在可用语言列表中 + if (langFromSettings && availableLocales.includes(langFromSettings)) { + determinedLang = langFromSettings; } else { - const navigatorLang = navigator.language?.split('-')[0]; - fetchedLang = navigatorLang === 'zh' ? 'zh' : defaultLng; - console.warn(`[SettingsStore] Invalid language setting ('${langFromSettings}') received from backend or missing. Falling back to '${fetchedLang}'.`); // <-- 修改日志 - // Optionally save the fallback language back - // await updateSetting('language', fetchedLang); + // 如果设置中的语言无效或缺失,尝试浏览器语言 + const navigatorLang = navigator.language?.split('-')[0]; + if (navigatorLang && availableLocales.includes(navigatorLang)) { + determinedLang = navigatorLang; + } else { + // 最后回退到 i18n 配置的默认语言 + determinedLang = defaultLng; + } + console.warn(`[SettingsStore] Invalid or missing language setting ('${langFromSettings}') received from backend. Falling back to '${determinedLang}'.`); + // Optionally save the fallback language back + // await updateSetting('language', determinedLang); } - if (fetchedLang) { - console.log(`[SettingsStore] Determined language: ${fetchedLang}. Calling setLocale...`); // <-- 添加日志 - setLocale(fetchedLang); + if (determinedLang) { + console.log(`[SettingsStore] Determined language: ${determinedLang}. Calling setLocale...`); // <-- 添加日志 + setLocale(determinedLang); } else { // This case should theoretically not happen with the fallback logic above console.error('[SettingsStore] Could not determine a valid language. This should not happen.'); @@ -235,7 +242,8 @@ export const useSettingsStore = defineStore('settings', () => { error.value = err.response?.data?.message || err.message || 'Failed to load settings'; // 出错时(例如未登录),根据浏览器语言设置回退语言 const navigatorLang = navigator.language?.split('-')[0]; - const fallbackLang = navigatorLang === 'zh' ? 'zh' : defaultLng; + // 错误时也检查浏览器语言是否可用 + const fallbackLang = (navigatorLang && availableLocales.includes(navigatorLang)) ? navigatorLang : defaultLng; console.log(`[SettingsStore] Error loading settings. Falling back to language: ${fallbackLang}. Calling setLocale...`); // <-- 添加日志 setLocale(fallbackLang); } finally { @@ -274,10 +282,12 @@ export const useSettingsStore = defineStore('settings', () => { // Update store state *after* successful API call settings.value = { ...settings.value, [key]: value }; - // If updating language, also update i18n - if (key === 'language' && (value === 'en' || value === 'zh')) { + // If updating language, check if it's valid and update i18n + if (key === 'language' && availableLocales.includes(value)) { console.log(`[SettingsStore] updateSetting: Language updated to ${value}. Calling setLocale...`); // <-- 添加日志 setLocale(value); + } else if (key === 'language') { + console.warn(`[SettingsStore] updateSetting: Attempted to set invalid language '${value}'. Ignoring i18n update.`); } } catch (err: any) { console.error(`更新设置项 '${key}' 失败:`, err); @@ -303,13 +313,19 @@ export const useSettingsStore = defineStore('settings', () => { 'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++ ]; const filteredUpdates: Partial = {}; - let languageUpdate: 'en' | 'zh' | undefined = undefined; + let languageUpdate: string | undefined = undefined; // Use string type for (const key in updates) { if (allowedKeys.includes(key as keyof SettingsState)) { filteredUpdates[key as keyof SettingsState] = updates[key]; - if (key === 'language' && (updates[key] === 'en' || updates[key] === 'zh')) { - languageUpdate = updates[key] as 'en' | 'zh'; + if (key === 'language') { + // Check if the language update is valid before storing it for setLocale + const langValue = updates[key]; + if (langValue && availableLocales.includes(langValue)) { + languageUpdate = langValue; // Store the valid language code + } else { + console.warn(`[SettingsStore] updateMultipleSettings: Received invalid language update '${langValue}'. Ignoring.`); + } } } else { console.warn(`[SettingsStore] 尝试批量更新不允许的设置键: ${key}`); diff --git a/packages/frontend/src/views/SettingsView.vue b/packages/frontend/src/views/SettingsView.vue index 9eec235..972d6e0 100644 --- a/packages/frontend/src/views/SettingsView.vue +++ b/packages/frontend/src/views/SettingsView.vue @@ -299,8 +299,9 @@
@@ -508,6 +509,7 @@ import { useAppearanceStore } from '../stores/appearance.store'; // 导入外观 import { useI18n } from 'vue-i18n'; import { storeToRefs } from 'pinia'; // setLocale is handled by the store now +import { availableLocales } from '../i18n'; // 导入可用语言列表 import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import { isAxiosError } from 'axios'; // 单独导入 isAxiosError import { startRegistration } from '@simplewebauthn/browser'; @@ -537,7 +539,14 @@ const { // --- Local state for forms --- const ipWhitelistInput = ref(''); // 使用 store 的 language getter 初始化 selectedLanguage -const selectedLanguage = ref<'en' | 'zh'>(storeLanguage.value); +const selectedLanguage = ref(storeLanguage.value); // 改为 string 类型以支持动态语言 +// 可选:创建一个语言名称映射 +const languageNames: Record = { + en: 'English', + zh: '中文', + jp: '日本語', // 添加日语或其他语言 + // Add more languages here as needed +}; const blacklistSettingsForm = reactive({ // Renamed to avoid conflict with store state name maxLoginAttempts: '5', // 初始值将在 watcher 中被 store 值覆盖 loginBanDuration: '300', // 初始值将在 watcher 中被 store 值覆盖