diff --git a/README.md b/README.md index a86b76d6..ada3dd8f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The dashboard provides visual management for all account types: - **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles -- **Language Switcher**: Toggle dashboard locale between English and Simplified Chinese +- **Language Switcher**: Toggle dashboard locale between English, Simplified Chinese, and Vietnamese **Analytics Dashboard** diff --git a/docs/i18n-dashboard.md b/docs/i18n-dashboard.md index 29cb28d4..58a0b293 100644 --- a/docs/i18n-dashboard.md +++ b/docs/i18n-dashboard.md @@ -13,6 +13,7 @@ Dashboard i18n currently covers UI text rendered by React components. - Supported locales today: - `en` (English) - `zh-CN` (Simplified Chinese) + - `vi` (Vietnamese) - Locale state is persisted in browser localStorage using `ccs-ui-locale`. - Fallback language is `en`. @@ -36,6 +37,7 @@ Out of scope: - Contains `resources` object with locale blocks: - `en.translation` - `zh-CN.translation` + - `vi.translation` - Uses `initReactI18next` for React integration. ### Locale utilities @@ -85,7 +87,7 @@ Examples exist in sync/account counters. ## Adding a New Locale -When adding a locale such as Vietnamese (`vi`): +When adding a locale such as Thai (`th`): 1. Add locale id to the supported locale list in `ui/src/lib/locales.ts`. 2. Add locale display label under `translation.locale` in `ui/src/lib/i18n.ts`. @@ -96,7 +98,7 @@ When adding a locale such as Vietnamese (`vi`): - `cd ui && bun run test:run tests/unit/ui/i18n/language-switcher.test.tsx` 6. Add or update key-parity tests to catch locale drift. -Tracking issue for comprehensive Vietnamese locale: +Current issue driving the Vietnamese rollout: - https://github.com/kaitranntt/ccs/issues/659 diff --git a/ui/README.md b/ui/README.md index e65660cd..cc1d3800 100644 --- a/ui/README.md +++ b/ui/README.md @@ -59,4 +59,5 @@ For full architecture, conventions, and locale onboarding, see: - UI locale persistence uses browser localStorage key `ccs-ui-locale`. - Current supported locales are managed in `ui/src/lib/locales.ts`. +- Current locales: `en`, `zh-CN`, `vi`. - Fallback locale is English (`en`). diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index 455c5ce5..66b4207d 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -399,6 +399,7 @@ function getStatusText(code: number): string { export function formatRelativeTime(modifiedSeconds: number, locale?: string): string { const formatLocale = getFormattingLocale(locale); const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; const now = Date.now(); const modified = modifiedSeconds * 1000; // Convert to milliseconds const diff = now - modified; @@ -408,10 +409,26 @@ export function formatRelativeTime(modifiedSeconds: number, locale?: string): st const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); - if (seconds < 60) return isZh ? '刚刚' : 'just now'; - if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`; - if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`; - if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`; + if (seconds < 60) { + if (isZh) return '刚刚'; + if (isVi) return 'vừa xong'; + return 'just now'; + } + if (minutes < 60) { + if (isZh) return `${minutes} 分钟前`; + if (isVi) return `${minutes} phút trước`; + return `${minutes}m ago`; + } + if (hours < 24) { + if (isZh) return `${hours} 小时前`; + if (isVi) return `${hours} giờ trước`; + return `${hours}h ago`; + } + if (days < 7) { + if (isZh) return `${days} 天前`; + if (isVi) return `${days} ngày trước`; + return `${days}d ago`; + } // Format as date for older logs const date = new Date(modified); @@ -441,25 +458,39 @@ export function getStatusColor(code: number): string { * Get error type label */ export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string { - const isZh = getFormattingLocale(locale) === 'zh-CN'; - const labels: Record = isZh - ? { - rate_limit: '限流', - auth: '认证错误', - not_found: '未找到', - server: '服务错误', - timeout: '超时', - unknown: '错误', - } - : { - rate_limit: 'Rate Limited', - auth: 'Auth Error', - not_found: 'Not Found', - server: 'Server Error', - timeout: 'Timeout', - unknown: 'Error', - }; - return labels[type] || (isZh ? '错误' : 'Error'); + const formatLocale = getFormattingLocale(locale); + if (formatLocale === 'zh-CN') { + const labels: Record = { + rate_limit: '限流', + auth: '认证错误', + not_found: '未找到', + server: '服务错误', + timeout: '超时', + unknown: '错误', + }; + return labels[type] || '错误'; + } + if (formatLocale === 'vi') { + const labels: Record = { + rate_limit: 'Quá giới hạn', + auth: 'Lỗi xác thực', + not_found: 'Không tìm thấy', + server: 'Lỗi máy chủ', + timeout: 'Hết thời gian chờ', + unknown: 'Lỗi', + }; + return labels[type] || 'Lỗi'; + } + + const labels: Record = { + rate_limit: 'Rate Limited', + auth: 'Auth Error', + not_found: 'Not Found', + server: 'Server Error', + timeout: 'Timeout', + unknown: 'Error', + }; + return labels[type] || 'Error'; } /** @@ -486,22 +517,35 @@ export function formatQuotaResetTimestamp( locale?: string ): string | null { if (!timestamp) return null; - const isZh = getFormattingLocale(locale) === 'zh-CN'; + const formatLocale = getFormattingLocale(locale); + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; try { const resetDate = new Date(timestamp); const now = new Date(); const diff = resetDate.getTime() - now.getTime(); - if (diff <= 0) return isZh ? '现在' : 'now'; + if (diff <= 0) { + if (isZh) return '现在'; + if (isVi) return 'bây giờ'; + return 'now'; + } const seconds = Math.floor(diff / 1000); - if (seconds < 60) return isZh ? `${seconds}秒` : `${seconds}s`; + if (seconds < 60) { + if (isZh) return `${seconds}秒`; + if (isVi) return `${seconds} giây`; + return `${seconds}s`; + } if (seconds < 3600) { const mins = Math.floor(seconds / 60); - return isZh ? `${mins}分钟` : `${mins}m`; + if (isZh) return `${mins}分钟`; + if (isVi) return `${mins} phút`; + return `${mins}m`; } const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`; + if (isVi) return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } catch { return null; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 52fdf832..0d2f79a1 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -8,6 +8,7 @@ const resources = { locale: { en: 'English', 'zh-CN': 'Simplified Chinese', + vi: 'Vietnamese', }, layout: { languageSwitcher: 'Language', @@ -1139,6 +1140,7 @@ const resources = { locale: { en: 'English', 'zh-CN': '简体中文', + vi: '越南语', }, layout: { languageSwitcher: '语言', @@ -2223,6 +2225,1163 @@ const resources = { }, }, }, + vi: { + translation: { + locale: { + en: 'Tiếng Anh', + 'zh-CN': 'Tiếng Trung giản thể', + vi: 'Tiếng Việt', + }, + layout: { + languageSwitcher: 'Ngôn ngữ', + }, + nav: { + general: 'Tổng quan', + home: 'Trang chủ', + analytics: 'Phân tích', + identityAccess: 'Nhận dạng & Quyền truy cập', + apiProfiles: 'Hồ sơ API', + cliproxyPlus: 'CLIProxy Plus', + cliproxyOverview: 'Tổng quan', + controlPanel: 'Bảng điều khiển', + githubCopilot: 'Phi công phụ GitHub', + cursorIde: 'IDE con trỏ', + accounts: 'Tài khoản', + allAccounts: 'Tất cả tài khoản', + sharedData: 'Dữ liệu được chia sẻ', + compatibleClis: 'CLI tương thích', + factoryDroid: 'Nhà máy Droid', + system: 'Hệ thống', + health: 'Sức khỏe', + settings: 'Cài đặt', + openrouterTooltip: 'Hơn 349 mô hình qua OpenRouter', + }, + home: { + profiles: 'Hồ sơ', + cliproxy: 'CLIProxy', + accounts: 'Tài khoản', + health: 'Sức khỏe', + configurationRequired: 'Yêu cầu cấu hình', + }, + auth: { + dashboardTitle: 'Bảng điều khiển CCS', + loginDescription: 'Nhập thông tin đăng nhập của bạn để truy cập bảng điều khiển', + username: 'Tên người dùng', + password: 'Mật khẩu', + usernamePlaceholder: 'Nhập tên người dùng', + passwordPlaceholder: 'Nhập mật khẩu', + signIn: 'Đăng nhập', + signingIn: 'Đang đăng nhập...', + loginFailed: 'Đăng nhập không thành công', + }, + commonToast: { + apiKeyRequired: 'Cần có khóa API', + accountAddedPresetFailed: + 'Đã thêm tài khoản nhưng không áp dụng được giá trị đặt trước mặc định', + settingsCopied: 'Đã sao chép cài đặt vào bảng nhớ tạm', + enterPresetName: 'Vui lòng nhập tên đặt trước để lưu', + codeCopied: 'Đã sao chép mã vào bảng nhớ tạm', + copyCodeFailed: 'Không sao chép được mã', + appliedModelAllTiers: 'Mô hình áp dụng cho tất cả các tầng', + settingsSaved: 'Đã lưu cài đặt', + defaultTargetUpdated: 'Đã cập nhật mục tiêu mặc định', + failedUpdateDefaultTarget: + 'Không cập nhật được mục tiêu mặc định thành {{target}}{{suffix}}', + }, + profileDialog: { + editTitle: 'Chỉnh sửa hồ sơ', + createTitle: 'Tạo hồ sơ API', + name: 'Tên', + namePlaceholder: 'api của tôi', + baseUrl: 'URL cơ sở', + baseUrlPlaceholder: 'https://api.example.com', + apiKey: 'Khóa API', + defaultModel: 'Mô hình mặc định (ANTHROPIC_MODEL)', + defaultModelHint: 'Để trống để sử dụng: {{model}}', + modelMappingTitle: 'Lập bản đồ mô hình (Opus/Sonnet/Haiku)', + modelMappingDesc: + 'Định cấu hình ID mô hình khác nhau cho mỗi cấp. Hữu ích cho các proxy API định tuyến các loại mô hình khác nhau đến các chương trình phụ trợ khác nhau.', + opusModel: 'Mô hình Opus (ANTHROPIC_DEFAULT_OPUS_MODEL)', + sonnetModel: 'Mô hình Sonnet (ANTHROPIC_DEFAULT_SONNET_MODEL)', + haikuModel: 'Mô hình Haiku (ANTHROPIC_DEFAULT_HAIKU_MODEL)', + cancel: 'Hủy bỏ', + saving: 'Đang lưu...', + update: 'Cập nhật', + create: 'Tạo nên', + }, + modelTierMapping: { + title: 'Ánh xạ tầng mô hình', + advanced: 'Trình độ cao', + description: + 'Định cấu hình các mô hình khác nhau cho các cấp độ opus/sonnet/haiku của Claude Code.', + autoSuggest: 'Tự động đề xuất dựa trên {{provider}}', + opusPlaceholder: 'ví dụ: nhân loại/claude-opus-4', + sonnetPlaceholder: 'ví dụ: nhân chủng học/claude-sonnet-4', + haikuPlaceholder: 'ví dụ: nhân loại/claude-3.5-haiku', + footer: + 'Các bộ này ANTHOPIC_DEFAULT_OPUS_MODEL, ANTHOPIC_DEFAULT_SONNET_MODEL, ANTHOPIC_DEFAULT_HAIKU_MODEL.', + }, + envEditor: { + sensitive: 'nhạy cảm', + none: 'Không có biến môi trường nào được cấu hình.', + noneHint: + 'Thêm các biến bằng cách sử dụng thông tin đầu vào bên dưới hoặc chỉnh sửa trực tiếp JSON.', + addVariable: 'Thêm biến môi trường', + keyPlaceholder: 'VARIABLE_NAME', + valuePlaceholder: 'giá trị', + }, + setupVariant: { + using: 'Sử dụng:', + variantNameRequired: 'Tên biến thể *', + variantNamePlaceholder: 'ví dụ: my-gemini, g3, flash', + invokeHintPrefix: 'Sử dụng tên này để gọi:', + model: 'Người mẫu', + modelPlaceholder: 'ví dụ: gemini-2.5-pro, claude-opus-4-5', + choosePresetInstead: 'Thay vào đó hãy chọn từ các cài đặt trước', + selectModel: 'Chọn một mô hình', + customModelName: 'Tên mẫu tùy chỉnh...', + enterAnyModel: 'Nhập bất kỳ tên mẫu máy nào được CLIProxy hỗ trợ', + defaultModel: 'Mặc định: {{model}}', + providerDefault: 'nhà cung cấp mặc định', + back: 'Mặt sau', + skip: 'Nhảy', + creating: 'Đang tạo...', + createVariant: 'Tạo biến thể', + skipHint: 'Bỏ qua nếu bạn chỉ muốn thêm tài khoản mà không tạo biến thể', + }, + customPresetDialog: { + title: 'Đặt trước tùy chỉnh', + presetNameOptional: 'Tên đặt trước (tùy chọn)', + presetNamePlaceholder: 'ví dụ: Cấu hình tùy chỉnh của tôi', + codexTipPrefix: 'Mẹo Codex: hậu tố', + codexTipSuffix: 'nỗ lực ghim. Các mô hình không có hậu tố sử dụng cài đặt Tư duy.', + defaultModel: 'Mẫu mặc định', + defaultModelDesc: 'Được sử dụng khi không yêu cầu cấp độ cụ thể', + opusModel: 'Opus (Có khả năng nhất)', + opusModelDesc: 'Đối với các nhiệm vụ lý luận phức tạp', + sonnetModel: 'Sonnet (Cân bằng)', + sonnetModelDesc: 'Cân bằng giữa tốc độ và khả năng', + haikuModel: 'Haiku (Nhanh)', + haikuModelDesc: 'Phản hồi nhanh cho các tác vụ đơn giản', + cancel: 'Hủy bỏ', + savePreset: 'Lưu cài đặt trước', + applyPreset: 'Áp dụng cài sẵn', + }, + componentModelSelector: { + selectModel: 'Chọn mô hình', + availableModelsCount: 'Các mẫu có sẵn ({{count}})', + preview: 'Xem trước', + }, + copilotSettings: { + enableCopilot: 'Kích hoạt phi công phụ', + enableCopilotDesc: 'Cho phép sử dụng đăng ký GitHub Copilot', + basicSettings: 'Cài đặt cơ bản', + accountType: 'Loại tài khoản', + accountTypeIndividual: 'Cá nhân', + accountTypeBusiness: 'Việc kinh doanh', + accountTypeEnterprise: 'Doanh nghiệp', + rateLimiting: 'Giới hạn tỷ lệ', + rateLimitSeconds: 'Giới hạn tốc độ (giây)', + noLimit: 'Không giới hạn', + waitOnRateLimit: 'Chờ giới hạn tỷ lệ', + waitOnRateLimitDesc: 'Chờ thay vì lỗi khi đạt đến giới hạn', + daemonSettings: 'Cài đặt Daemon', + autoStartDaemon: 'Daemon tự động khởi động', + autoStartDaemonDesc: 'Bắt đầu copilot-api khi sử dụng hồ sơ', + }, + providerModelSelector: { + selectModel: 'Chọn mô hình', + noModelsForProvider: 'Không có mẫu nào cho nhà cung cấp này', + broken: 'BROKEN', + deprecated: 'DEPRECATED', + paid: 'PAID', + freeTier: 'Bậc miễn phí', + paidTier: 'Cấp trả phí', + modelKnownIssues: 'Mô hình này có vấn đề đã biết', + viewIssueDetails: 'Xem chi tiết vấn đề', + modelDeprecated: 'Mô hình này không được dùng nữa', + recommended: 'Khuyến khích', + allModelsCount: 'Tất cả các mẫu ({{count}})', + noModelsAvailable: 'Không có mẫu nào có sẵn', + }, + createAuthProfileDialog: { + title: 'Tạo tài khoản mới', + description: + 'Hồ sơ xác thực yêu cầu đăng nhập Claude CLI. Chạy lệnh dưới đây trong terminal của bạn. Bạn có thể chỉnh sửa chế độ đồng bộ hóa, nhóm và độ liên tục sau này từ bảng Tài khoản.', + profileName: 'Tên hồ sơ', + profileNamePlaceholder: 'ví dụ: công việc, cá nhân, khách hàng', + invalidProfileName: + 'Tên phải bắt đầu bằng một chữ cái và chỉ chứa các chữ cái, số, dấu gạch ngang hoặc dấu gạch dưới.', + enableSharedHistory: 'Bật đồng bộ hóa lịch sử chia sẻ với các tài khoản xác thực ccs khác', + historySyncGroupOptional: 'Nhóm đồng bộ hóa lịch sử (tùy chọn)', + historySyncGroupPlaceholder: 'mặc định, chạy nước rút-a, client-x', + historySyncGroupHint: + 'Để trống để sử dụng nhóm chia sẻ mặc định. Dấu cách được chuẩn hóa thành dấu gạch ngang.', + deeperContinuity: 'Nâng cao: chế độ liên tục sâu hơn', + deeperContinuityHint: + 'Thêm đồng bộ hóa cho session-env, file-history, shell-snapshots và todos. Thông tin xác thực vẫn bị cô lập.', + invalidContextGroup: + 'Nhóm phải bắt đầu bằng một chữ cái và chỉ sử dụng các chữ cái, số, dấu gạch ngang hoặc dấu gạch dưới (tối đa {{max}} ký tự).', + command: 'Yêu cầu', + commandFallback: 'ccs auth create ', + afterRunPrefix: 'Sau khi chạy lệnh:', + afterRunStep1: 'Hoàn tất đăng nhập Claude trong trình duyệt của bạn', + afterRunStep2: 'Quay lại đây và làm mới để xem tài khoản mới', + poolingHint: + 'Thay vào đó, bạn muốn định tuyến Claude OAuth gộp lại? Sử dụng nhóm CLIProxy Claude từ nút hành động của trang Tài khoản.', + close: 'Đóng', + copied: 'Đã sao chép!', + copyCommand: 'Sao chép lệnh', + }, + cliproxyModelCategory: { + google: 'Google (Song Tử)', + openai: 'OpenAI (GPT)', + anthropic: 'Nhân loại (Claude)', + antigravity: 'phản trọng lực', + other: 'Khác', + }, + cliproxyOverviewComponents: { + availableModels: 'Các mẫu có sẵn', + noModelsAvailable: 'Không có mẫu nào có sẵn', + startSessionHint: 'Bắt đầu phiên CLIProxy để tìm nạp các mô hình có sẵn', + totalCount: 'tổng cộng {{count}}', + availableThroughGroupedByProvider: + 'Các mô hình có sẵn đến {{backendLabel}}, được nhóm theo nhà cung cấp', + moreCount: '+{{count}} thêm', + }, + categorizedModelSelector: { + selectModel: 'Chọn một mô hình', + noModelsAvailable: 'Không có mẫu nào có sẵn', + modelPlaceholder: 'Người mẫu...', + }, + openrouterQuickStart: { + recommended: 'Khuyến khích', + title: 'Bắt đầu với OpenRouter', + description: + 'Truy cập các mô hình {{modelCountLabel}} từ OpenAI, Anthropic, Google, Meta, v.v. - tất cả đều thông qua một API.', + featureOneApi: 'Một API, tất cả các nhà cung cấp', + featureTierMapping: 'Ánh xạ tầng mô hình', + createOpenRouterProfile: 'Tạo hồ sơ OpenRouter', + getApiKeyAt: 'Nhận khóa API của bạn tại', + or: 'hoặc', + createCustomProfile: 'Tạo hồ sơ API tùy chỉnh', + }, + credentialHealth: { + title: 'Tình trạng thông tin xác thực', + ready: 'Sẵn sàng', + noCredentialsConfigured: + 'Không có thông tin xác thực nào được định cấu hình. Sử dụng các nút đăng nhập ở trên để xác thực.', + neverUsed: 'Chưa bao giờ sử dụng', + justNow: 'Vừa rồi', + unknown: 'Không xác định', + daysAgo: '{{count}}ngày trước', + hoursAgo: '{{count}} giờ trước', + minutesAgo: '{{count}} phút trước', + }, + proxyStatusWidget: { + justNow: 'ngay bây giờ', + minutesAgo: '{{count}} phút trước', + hoursAgo: '{{count}} giờ trước', + syncReady: 'Sẵn sàng đồng bộ hóa', + noConfig: 'Không có cấu hình', + remoteProxy: 'Proxy từ xa', + active: 'Tích cực', + trafficAutoRouted: 'Lưu lượng truy cập tự động được định tuyến đến máy chủ từ xa', + tooltipSync: 'Đồng bộ hóa hồ sơ với CLIProxy', + tooltipRestart: 'Khởi động lại', + tooltipStop: 'Dừng lại', + tooltipClose: 'Đóng', + tooltipVersionSettings: 'Cài đặt phiên bản', + versionManagement: 'Quản lý phiên bản', + selectVersionPlaceholder: 'Chọn phiên bản để cài đặt...', + stable: '(ổn định)', + install: 'Cài đặt', + versionsAboveUnstable: 'Các phiên bản trên {{version}} có vấn đề đã biết', + lastChecked: 'Đã kiểm tra lần cuối {{time}}', + notRunning: 'Không chạy', + start: 'Bắt đầu', + port: 'Cổng {{port}}', + sessionCount: '{{count}} phiên', + sessionCount_other: '{{count}} phiên', + installUnstableTitle: 'Cài đặt phiên bản không ổn định?', + installUnstableDesc: + 'Bạn sắp cài đặt v{{version}}, phiên bản này cao hơn phiên bản ổn định tối đa v{{maxStable}}.', + installUnstableWarning: + 'Phiên bản này đã biết có vấn đề về độ ổn định và có thể gây ra hành vi không mong muốn.', + installUnstableConfirm: 'Bạn có chắc chắn muốn tiếp tục không?', + cancel: 'Hủy bỏ', + installAnyway: 'Vẫn cài đặt', + clickToDowngrade: 'Bấm để hạ cấp', + clickToUpdate: 'Bấm để cập nhật', + }, + syncStatusCard: { + profileSync: 'Đồng bộ hồ sơ', + ready: 'Sẵn sàng', + noConfig: 'Không có cấu hình', + syncsProfilesDesc: 'Đồng bộ hóa cấu hình API với cấu hình CLIProxy cục bộ', + runDoctorHint: 'Chạy ccs doctor --fix để tạo config.', + details: 'Chi tiết', + syncNow: 'Đồng bộ hóa ngay bây giờ', + }, + syncDialog: { + title: 'Đồng bộ hóa hồ sơ với CLIProxy cục bộ', + description: 'Đồng bộ hóa cấu hình API CCS của bạn với CLIProxy config.yaml cục bộ.', + noProfiles: 'Không có cấu hình nào được định cấu hình để đồng bộ hóa.', + createProfilesFirst: 'Trước tiên hãy tạo hồ sơ API bằng tab Hồ sơ.', + modelLabel: 'Người mẫu:', + ready: 'Sẵn sàng', + profilesToSync_one: '{{count}} hồ sơ để đồng bộ hóa', + profilesToSync_other: '{{count}} hồ sơ cần đồng bộ hóa', + cancel: 'Hủy bỏ', + syncing: 'Đang đồng bộ hóa...', + synced: 'Đã đồng bộ hóa!', + syncNow: 'Đồng bộ hóa ngay bây giờ', + }, + errorLogs: { + title: 'Nhật ký lỗi', + failedRequests_one: '{{count}} yêu cầu không thành công', + failedRequests_other: '{{count}} yêu cầu không thành công', + cliproxyDiagnostics: 'Chẩn đoán CLIProxy', + showingCount: 'Hiển thị 50 trong số {{total}} nhật ký', + selectLog: 'Chọn nhật ký để xem chi tiết', + failedLoadContent: 'Không tải được nội dung nhật ký', + copyAbsolutePath: 'Sao chép đường dẫn tuyệt đối', + copyRawContent: 'Sao chép nội dung nhật ký thô', + tabOverview: 'Tổng quan', + tabHeaders: 'Tiêu đề', + tabRequest: 'Lời yêu cầu', + tabResponse: 'Phản ứng', + tabRaw: 'thô', + modelLabel: 'Người mẫu:', + quotaResetsIn: 'Đặt lại hạn ngạch trong', + method: 'Phương pháp', + provider: 'nhà cung cấp', + version: 'Phiên bản', + endpoint: 'Điểm cuối', + url: 'URL', + timestamp: 'Dấu thời gian', + na: 'không áp dụng', + noHeaders: 'Không có tiêu đề nào', + noRequestBody: 'Không có nội dung yêu cầu', + noResponseBody: 'Không có cơ quan phản hồi', + rateLimitedMsg: + 'Tỷ lệ giới hạn. Chuyển sang tài khoản khác hoặc đợi đặt lại hạn ngạch. Mẫu {{model}} đã hết hạn mức.', + rateLimitedMsgNoModel: + 'Tỷ lệ giới hạn. Chuyển sang tài khoản khác hoặc đợi đặt lại hạn ngạch.', + authFailedMsg: + 'Xác thực không thành công. Xác thực lại thông qua Cài đặt CLIProxy hoặc kiểm tra khóa API.', + notFoundMsg: + 'Không tìm thấy điểm cuối. Điểm cuối này có thể không tồn tại trên nhà cung cấp này.', + serverErrorMsg: + 'Lỗi máy chủ từ thượng nguồn. Hãy thử lại sau hoặc kiểm tra trang trạng thái của nhà cung cấp.', + timeoutMsg: + 'Yêu cầu đã hết thời gian chờ. Kiểm tra kết nối mạng hoặc tăng cài đặt thời gian chờ.', + unknown: 'không rõ', + }, + editAccountContext: { + title: 'Chỉnh sửa đồng bộ hóa lịch sử', + description: + 'Định cấu hình cách "{{name}}" chia sẻ lịch sử và tính liên tục với các tài khoản xác thực ccs khác.', + syncMode: 'Chế độ đồng bộ', + selectContextMode: 'Chọn chế độ ngữ cảnh', + isolatedOption: 'bị cô lập (không đồng bộ)', + sharedOption: 'được chia sẻ (đã bật đồng bộ hóa)', + sharedModeHint: + 'Chế độ chia sẻ sử dụng lại ngữ cảnh không gian làm việc cho các tài khoản trong cùng một nhóm đồng bộ lịch sử.', + isolatedModeHint: + 'Chế độ biệt lập giúp tài khoản này tách biệt hoàn toàn với các tài khoản xác thực ccs khác.', + historySyncGroup: 'Nhóm đồng bộ hóa lịch sử', + groupPlaceholder: 'mặc định', + groupHint: + 'Chuẩn hóa thành chữ thường (dấu cách trở thành dấu gạch ngang). Được phép: chữ cái, số, _ , - (tối đa {{max}} ký tự).', + invalidGroup: 'Nhập tên nhóm hợp lệ bắt đầu bằng một chữ cái.', + continuityDepth: 'Độ sâu liên tục', + selectContinuityDepth: 'Chọn độ sâu liên tục', + standardOption: 'tiêu chuẩn (chỉ dự án)', + deeperOption: 'tính liên tục sâu hơn (nâng cao)', + deeperHint: + 'Chế độ nâng cao cũng đồng bộ hóa phiên-env, lịch sử tệp, ảnh chụp nhanh shell và todos.', + standardHint: 'Chế độ tiêu chuẩn chỉ đồng bộ hóa bối cảnh không gian làm việc của dự án.', + credentialsIsolated: + 'Thông tin xác thực và .anthropic vẫn được tách riêng cho mỗi tài khoản ở tất cả các chế độ.', + cancel: 'Hủy bỏ', + save: 'Cứu', + saving: 'Đang lưu...', + }, + historySyncLearningMap: { + title: 'Cách đồng bộ hóa lịch sử hoạt động', + description: + 'Bị cô lập -> Được chia sẻ -> Sâu sắc hơn. Sử dụng Đồng bộ hóa mỗi hàng cho tất cả các thay đổi.', + learningMap: 'Bản đồ học tập', + isolated: 'Bị cô lập', + shared: 'Đã chia sẻ', + deeper: 'sâu hơn', + cliproxyManaged_one: + '{{count}} Tài khoản nhóm CLIProxy Claude được quản lý trong trang Action Center / CLIProxy.', + cliproxyManaged_other: + '{{count}} Tài khoản CLIProxy Claude pool được quản lý trong trang Action Center / CLIProxy.', + showDetails: 'Hiển thị chi tiết: nhóm, chuyển đổi và chính sách kế thừa', + modeSwitch: 'Chuyển đổi chế độ', + modeSwitchDesc: + 'Hộp thoại đồng bộ hóa cho phép người dùng di chuyển giữa cách ly/chia sẻ và chọn tính liên tục sâu hơn.', + historySyncGroup: 'Nhóm đồng bộ hóa lịch sử', + historySyncGroupDesc: + 'Cùng một nhóm có nghĩa là làn đường bối cảnh dự án được chia sẻ. Dự phòng mặc định là mặc định.', + }, + accountsTable: { + name: 'Tên', + type: 'Kiểu', + defaultBadge: 'mặc định', + created: 'Tạo', + lastUsed: 'Được sử dụng lần cuối', + historySync: 'Đồng bộ hóa lịch sử', + actions: 'hành động', + syncTitle: 'Chỉnh sửa chế độ đồng bộ, nhóm và độ sâu liên tục', + confirmLegacyTitle: 'Xác nhận chế độ hiện tại của tài khoản cũ này là rõ ràng', + sync: 'Đồng bộ hóa', + confirm: 'Xác nhận', + active: 'Tích cực', + setDefault: 'Đặt mặc định', + deleteAccount: 'Xóa tài khoản', + cannotDeleteDefault: 'Không thể xóa tài khoản mặc định', + noAccounts: + 'Không tìm thấy tài khoản xác thực CCS nào. Sử dụng ccs auth create để thêm tài khoản.', + resetToDefault: 'Đặt lại về mặc định CCS', + deleteDialogTitle: 'Xóa tài khoản', + deleteDialogDesc: + 'Bạn có chắc chắn muốn xóa tài khoản "{{name}}" không? Thao tác này sẽ xóa hồ sơ và tất cả dữ liệu phiên của nó. Không thể hoàn tác hành động này.', + cancel: 'Hủy bỏ', + delete: 'Xóa bỏ', + sharedGroupStandard: 'được chia sẻ ({{group}}, tiêu chuẩn)', + sharedGroupDeeper: 'đã chia sẻ ({{group}}, sâu hơn)', + sharedGroupLegacy: 'được chia sẻ ({{group}}, kế thừa tiêu chuẩn)', + isolatedLegacy: 'bị cô lập (mặc định cũ)', + isolated: 'bị cô lập', + }, + addAccountDialog: { + title: 'Thêm tài khoản {{displayName}}', + descKiro: + 'Chọn phương thức xác thực Kiro, sau đó xác thực qua trình duyệt hoặc nhập từ Kiro IDE.', + descDeviceCode: + 'Nhấp vào Xác thực. Mã xác minh sẽ xuất hiện để bạn nhập vào trang web của nhà cung cấp.', + descOauth: + 'Nhấp vào Xác thực để nhận URL OAuth. Mở nó trong bất kỳ trình duyệt nào để đăng nhập.', + powerUserEnabled: 'Đã bật chế độ người dùng cấp quyền', + powerUserSkipped: + 'Danh sách kiểm tra trách nhiệm của AGY bị bỏ qua khỏi Cài đặt > Proxy. Bạn chấp nhận hoàn toàn trách nhiệm về rủi ro tài khoản/OAuth.', + authMethod: 'Phương thức xác thực', + selectKiroAuthMethod: 'Chọn phương thức xác thực Kiro', + nicknameRequired: 'Biệt danh (bắt buộc)', + nicknameOptional: 'Biệt danh (tùy chọn)', + nicknamePlaceholder: 'ví dụ: công việc, cá nhân', + nicknameRequiredHint: + 'Cần thiết cho nhà cung cấp này. Sử dụng tên thân thiện duy nhất (ví dụ: cơ quan, cá nhân).', + nicknameOptionalHint: + 'Một cái tên thân thiện để xác định tài khoản này. Tự động tạo từ email nếu để trống.', + waitingForAuth: 'Đang chờ xác thực...', + deviceCodeHint: + 'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.', + browserHint: + 'Hoàn tất xác thực trong trình duyệt của bạn. Hộp thoại này sẽ tự động đóng lại.', + openUrlLabel: 'Mở URL này trong bất kỳ trình duyệt nào để đăng nhập:', + copied: 'Đã sao chép', + copy: 'Sao chép', + open: 'Mở', + popupBlocked: + 'Cửa sổ bật lên bị chặn. Sao chép URL ở trên và mở thủ công trong trình duyệt của bạn.', + redirectPasteLabel: 'Chuyển hướng không hoạt động? Dán URL gọi lại:', + callbackPlaceholder: 'Dán URL chuyển hướng vào đây...', + submitCallback: 'Gửi cuộc gọi lại', + submitting: 'Đang gửi...', + preparingUrl: 'Đang chuẩn bị URL đăng nhập...', + cancel: 'Hủy bỏ', + importingToken: 'Đang nhập mã thông báo từ Kiro IDE...', + authenticate: 'Xác thực', + importFromIde: 'Nhập từ IDE', + importing: 'Đang nhập khẩu...', + }, + flowViz: { + backToProviders: 'Quay lại nhà cung cấp', + showDetails: 'Hiển thị chi tiết', + hideDetails: 'Ẩn chi tiết', + resetLayout: 'Đặt lại bố cục', + provider: 'nhà cung cấp', + totalRequests: 'Tổng số yêu cầu', + accounts: 'Tài khoản', + connectionTimeline: 'Dòng thời gian kết nối', + noRecentConnections: 'Không có kết nối gần đây', + }, + quickStats: { + totalRequests: 'Tổng số yêu cầu', + successRate: 'Tỷ lệ thành công', + totalTokens: 'Tổng số token', + activeModels: 'Mô hình hoạt động', + }, + authMonitor: { + accounts: 'Tài khoản', + accountsCount: '{{count}} tài khoản', + success: 'Thành công', + failed: 'Thất bại', + successRate: 'Tỷ lệ thành công', + }, + accountCard: { + resumeAccount: 'Tiếp tục tài khoản', + pauseAccount: 'Tạm dừng tài khoản', + quotaLoading: 'Hạn ngạch...', + quota: 'hạn ngạch', + quotaUnavailable: 'Giới hạn hạn ngạch không có sẵn', + reauthNeeded: 'Reauth cần thiết', + removeAndReadd: 'Xóa và thêm lại tài khoản', + autoRefreshFailed: 'Tự động làm mới không thành công', + }, + antigravityChecklist: { + title: 'Trách nhiệm OAuth chống trọng lực', + subtitle: 'Hoàn thành tất cả 4 bước trước khi bạn có thể xác thực.', + mandatory: 'Bắt buộc', + completion: 'Hoàn thành', + stepsCount: '{{current}}/4 bước', + }, + accountSafetyWarning: { + title: 'Cảnh báo an toàn tài khoản OAuth', + subtitle: 'Vấn đề #509 · Rủi ro Gemini + AGY OAuth', + firstLine: + 'Vấn đề số 509 báo cáo về việc đình chỉ/cấm tài liệu gắn liền với ccs agy và việc sử dụng tài khoản chung giữa ccs gemini và ccs agy.', + secondLine: + 'Chỉ tiếp tục nếu bạn chấp nhận hoàn toàn trách nhiệm về OAuth và rủi ro truy cập tài khoản.', + issueLabel: 'Đọc vấn đề #509', + proxySettingsLabel: 'Điều khiển Gemini + AGY: Cài đặt > Proxy', + }, + cliproxyDialog: { + name: 'Tên', + provider: 'nhà cung cấp', + account: 'Tài khoản', + accountOptional: 'Tài khoản (tùy chọn)', + model: 'Người mẫu', + modelOptional: 'Mô hình (tùy chọn)', + defaultTarget: 'Mục tiêu mặc định', + defaultTier: 'Cấp mặc định', + tierConfig: 'Cấu hình cấp độ', + selectProvider: 'Chọn nhà cung cấp...', + useDefaultAccount: 'Sử dụng tài khoản mặc định', + defaultSuffix: '(mặc định)', + opus: 'tác phẩm', + sonnet: 'bài sonnet', + haiku: 'thơ haiku', + claudeCode: 'Mã Claude', + factoryDroid: 'Nhà máy Droid', + cancel: 'Hủy bỏ', + create: 'Tạo nên', + creating: 'Đang tạo...', + saveChanges: 'Lưu thay đổi', + saving: 'Đang lưu...', + placeholderName: 'Song Tử của tôi', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'tổng hợp của tôi', + placeholderModelId: 'id mẫu', + placeholderAccountId: 'id tài khoản', + editCompositeTitle: 'Chỉnh sửa biến thể tổng hợp: {{name}}', + editSingleTitle: 'Chỉnh sửa một biến thể: {{name}}', + }, + profileEditor: { + profileInfo: 'Thông tin hồ sơ', + profileName: 'Tên hồ sơ', + filePath: 'Đường dẫn tệp', + lastModified: 'Sửa đổi lần cuối', + defaultTarget: 'Mục tiêu mặc định', + quickUsage: 'Sử dụng nhanh', + runWithProfile: 'Chạy với hồ sơ', + runOnDroid: 'Chạy trên Droid', + droidAliasExplicit: 'Bí danh Droid (rõ ràng)', + overrideToClaude: 'Ghi đè lên Claude', + overrideToClaudeExplicit: 'Ghi đè lên Claude (rõ ràng)', + setAsDefault: 'Đặt làm mặc định', + provider: 'nhà cung cấp', + custom: 'Phong tục', + }, + settings: { + sectionLoadFailed: 'Không tải được phần', + unknownError: 'Đã xảy ra lỗi không xác định', + reloadPage: 'Tải lại trang', + refresh: 'Làm cho khỏe lại', + copy: 'Sao chép', + copied: 'Đã sao chép', + saved: 'Đã lưu', + loading: 'Đang tải...', + configFileNotFound: 'Không tìm thấy tập tin cấu hình', + }, + settingsTabs: { + web: 'Web', + env: 'Env', + think: 'Nghĩ', + proxy: 'ủy quyền', + auth: 'Xác thực', + backup: 'Hỗ trợ', + }, + settingsAuth: { + failedFetchTokens: 'Không tìm nạp được mã thông báo xác thực', + failedSaveTokens: 'Không lưu được mã thông báo', + tokensUpdated: 'Đã cập nhật mã thông báo. Khởi động lại CLIProxy để áp dụng.', + failedRegenerate: 'Không thể tạo lại bí mật', + secretRegenerated: 'Bí mật quản lý mới được tạo. Khởi động lại CLIProxy để áp dụng.', + failedReset: 'Không thể đặt lại mã thông báo', + tokensReset: 'Mã thông báo được đặt lại về mặc định. Khởi động lại CLIProxy để áp dụng.', + description: + 'Định cấu hình mã thông báo xác thực CLIProxy. Các thay đổi yêu cầu khởi động lại CLIProxy.', + apiKey: 'Khóa API', + custom: 'Phong tục', + apiKeyDesc: 'Được sử dụng bởi Claude Code để xác thực bằng CLIProxy', + apiKeyPlaceholder: 'Khóa API', + managementSecret: 'Bí mật quản lý', + managementSecretDesc: 'Được bảng điều khiển CCS sử dụng để truy cập API quản lý CLIProxy', + managementSecretPlaceholder: 'Bí mật quản lý', + generateSecret: 'Tạo bí mật an toàn mới', + resetDefaults: 'Đặt lại về mặc định', + resetDesc: 'Đặt lại cả khóa API và bí mật quản lý về giá trị mặc định.', + saving: 'Đang lưu...', + save: 'Cứu', + }, + settingsGlobalEnv: { + description: + 'Các biến môi trường được đưa vào tất cả các hồ sơ đăng ký không phải của Claude (gemini, codex, agy, ghcp, v.v.)', + enabled: 'Đã bật Env toàn cầu', + disabled: 'Env toàn cầu bị vô hiệu hóa', + enabledDesc: 'Các lọ Env sẽ được đưa vào hồ sơ của bên thứ ba', + disabledDesc: 'Env vars sẽ không được tiêm', + envVars: 'Biến môi trường', + noneConfigured: 'Không có biến môi trường nào được định cấu hình', + addNew: 'Thêm biến mới', + keyName: 'KEY_NAME', + value: 'giá trị', + add: 'Thêm vào', + quickAdd: 'Thêm nhanh các biến phổ biến', + allConfigured: 'Tất cả các biến chung được cấu hình', + }, + settingsBackups: { + failedFetch: 'Không tìm nạp được bản sao lưu', + failedRestore: 'Không thể khôi phục bản sao lưu', + restoreSuccess: 'Đã khôi phục bản sao lưu thành công', + title: 'Sao lưu cài đặt', + description: + 'Khôi phục các phiên bản trước của tệp settings.json của bạn. Bản sao lưu được tạo tự động khi cài đặt được sửa đổi.', + none: 'Không có bản sao lưu nào', + noneHint: 'Bản sao lưu sẽ xuất hiện ở đây khi bạn sửa đổi cài đặt', + latest: 'Mới nhất', + restoring: 'Đang khôi phục...', + restore: 'Khôi phục', + restoreConfirmTitle: 'Khôi phục bản sao lưu?', + restoreConfirmPrefix: 'Điều này sẽ thay thế cài đặt hiện tại của bạn bằng bản sao lưu từ', + restoreConfirmSuffix: 'Không thể hoàn tác hành động này.', + cancel: 'Hủy bỏ', + }, + settingsWebsearch: { + description: + 'Tìm kiếm trên web dựa trên CLI cho hồ sơ của bên thứ ba (gemini, codex, agy, v.v.)', + enabled: 'Đã bật Tìm kiếm trên web', + disabled: 'Tìm kiếm trên web bị vô hiệu hóa', + checking: 'Đang kiểm tra trạng thái...', + providers: 'Nhà cung cấp', + noneConfigured: 'Không có nhà cung cấp nào được định cấu hình', + noneConfiguredHint: + 'Cài đặt công cụ CLI để kích hoạt các nhà cung cấp dịch vụ tìm kiếm trên web', + checkProviders: 'Kiểm tra các nhà cung cấp', + installed: 'đã cài đặt', + notInstalled: 'chưa được cài đặt', + model: 'Người mẫu:', + howInstall: 'Cách cài đặt {{name}} CLI', + installGlobally: 'Cài đặt trên toàn cầu', + requiresKey: '(yêu cầu khóa API xAI)', + freeTier: '(Có sẵn cấp MIỄN PHÍ)', + viewDocs: 'Xem tài liệu', + }, + settingsProxy: { + failedLoadAgyMode: 'Không tải được chế độ người dùng cấp nguồn AGY', + failedUpdateAgyMode: 'Không thể cập nhật chế độ người dùng thành thạo AGY', + failedVerifyAgyMode: 'Không thể xác minh tính bền vững của chế độ người dùng cấp nguồn AGY', + notPersistedAgyMode: + 'Chế độ sử dụng nguồn AGY không được duy trì. Cấu hình có thể đã được sửa đổi bởi một quy trình khác.', + agyModeEnabled: 'Đã bật chế độ người dùng cấp nguồn AGY.', + agyModeDisabled: 'Chế độ người dùng có quyền lực AGY bị tắt.', + typePhraseToContinue: 'Nhập "{{value}}" để tiếp tục.', + description: + 'Định cấu hình kết nối {{backend}} cục bộ hoặc từ xa cho cấu hình dựa trên proxy', + backendPlus: 'CLIProxy Plus', + backend: 'CLIProxy', + instanceStatus: 'Trạng thái phiên bản', + connectionMode: 'Chế độ kết nối', + local: 'Địa phương', + remote: 'Xa', + localDesc: 'Chạy nhị phân {{backend}} trên máy này', + remoteDesc: 'Kết nối với máy chủ {{backend}} từ xa', + backendBinary: 'Nhị phân phụ trợ', + stopProxyToSwitch: + 'Dừng proxy đang chạy trong Trạng thái phiên bản để chuyển đổi chương trình phụ trợ.', + default: 'Mặc định', + plusDesc: 'Hỗ trợ nhà cung cấp đầy đủ bao gồm Kiro và GitHub Copilot', + originalDesc: 'Nhị phân gốc (chỉ Gemini, Codex, AntiGravity)', + variantsIncompatible: + 'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.', + safety: 'Sự an toàn', + agyModeTitle: 'Chế độ người dùng năng lượng chống trọng lực', + agyModeDesc: + 'Bỏ qua danh sách kiểm tra trách nhiệm AGY trong quy trình Thêm tài khoản và `ccs agy`.', + agyWarning: + 'Chỉ sử dụng nếu bạn hiểu đầy đủ về mẫu rủi ro bị đình chỉ/cấm OAuth (#509). CCS không thể chịu trách nhiệm về việc mất tài khoản.', + finalConfirm: 'Yêu cầu xác nhận cuối cùng', + finalConfirmDesc: + 'Việc bật tính năng này sẽ bỏ qua các điểm kiểm tra an toàn AGY trong cả bảng điều khiển và CLI. Xem lại vấn đề #509 và nhập cụm từ chính xác để tiếp tục.', + step1: 'Bước 1', + readIssue: 'Đọc vấn đề #509', + step2: 'Bước 2', + typePrefix: 'Kiểu', + typeSuffix: 'để kích hoạt.', + typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ người dùng nâng cao Antigravity', + exactPhrase: 'Yêu cầu chính xác cụm từ.', + enableAgyMode: 'Bật Chế độ người dùng cấp cao', + toggleAgyMode: 'Chuyển đổi chế độ người dùng thành thạo AGY', + fallbackSettings: 'Cài đặt dự phòng', + enableFallback: 'Cho phép dự phòng về cục bộ', + enableFallbackDesc: 'Sử dụng proxy cục bộ nếu không thể truy cập được từ xa', + autoStartLocal: 'Tự động khởi động proxy cục bộ', + autoStartLocalDesc: 'Tự động khởi động proxy cục bộ khi dự phòng', + advanced: 'Trình độ cao', + debugMode: 'Chế độ gỡ lỗi', + debugModeDesc: 'Bật chẩn đoán dành cho nhà phát triển trong bảng điều khiển trình duyệt', + debugModeEnabled: + 'Đã bật chế độ gỡ lỗi. Kiểm tra bảng điều khiển trình duyệt để biết nhật ký chi tiết.', + localProxy: 'Proxy cục bộ', + port: 'Cảng', + autoStart: 'Tự động khởi động', + localAutoStartDesc: 'Tự động khởi động proxy cục bộ khi cần', + remoteConfigTitle: 'Cấu hình máy chủ từ xa', + host: 'Chủ nhà', + hostPlaceholder: '192.168.1.100 hoặc proxy.example.com', + defaultPort: 'mặc định: {{value}}', + portPlaceholder: 'Để trống trong {{value}}', + protocol: 'Giao thức', + http: 'HTTP', + https: 'HTTPS', + apiKeyOptional: 'Khóa API (tùy chọn)', + apiKeyPlaceholder: 'Đối với điểm cuối API /v1/*', + apiKeyDesc: 'Được sử dụng cho các yêu cầu API tới /v1/chat/completions', + managementKeyOptional: 'Khóa quản lý (tùy chọn)', + managementKeyPlaceholder: 'Đối với điểm cuối /v0/management/*', + managementKeyDesc: + 'Được sử dụng cho API quản lý trang tổng quan. Quay trở lại Khóa API nếu không được đặt.', + testing: 'Đang thử nghiệm...', + testConnection: 'Kiểm tra kết nối', + connectedLatency: 'Đã kết nối ({{value}}ms)', + connectionFailed: 'Kết nối không thành công', + invalidPortOrEmpty: 'Cổng phải là số nguyên từ 1 đến 65535 hoặc để trống theo mặc định', + invalidLocalPort: 'Cổng cục bộ phải là số nguyên từ 1 đến 65535', + backendPlusApi: 'CLIProxyAPIPlus', + backendApi: 'CLIProxyAPI', + }, + settingsThinking: { + description: 'Định cấu hình tư duy/lý luận mở rộng cho các mô hình được hỗ trợ.', + supportedProviders: 'Nhà cung cấp được hỗ trợ', + modeTitle: 'Chế độ suy nghĩ', + modeAutoDesc: 'Tự động thiết lập tư duy dựa trên tầng mô hình', + modeOffDesc: 'Vô hiệu hóa suy nghĩ mở rộng', + modeManualDesc: 'Đặt mức ghi đè liên tục hoặc sử dụng cờ CLI mỗi lần chạy', + tierDefaults: 'Mặc định cấp độ', + tierDefaultsDesc: 'Mức độ tư duy mặc định cho từng tầng mô hình khi ở chế độ tự động.', + persistentOverride: 'Ghi đè liên tục', + persistentOverrideDesc: 'Áp dụng cho tất cả các buổi. Cờ CLI vẫn được ưu tiên.', + enterCustomBudget: 'Nhập ngân sách tùy chỉnh', + apply: 'Áp dụng', + providerOverrides: 'Ghi đè nhà cung cấp ({{count}})', + providerOverridesDesc: + 'Ghi đè mặc định cấp độ cho các nhà cung cấp cụ thể. Thêm khóa nhà cung cấp tùy chỉnh nếu cần.', + addProviderPlaceholder: 'Thêm khóa nhà cung cấp (ví dụ: qwen)', + showWarnings: 'Hiển thị cảnh báo', + showWarningsDesc: 'Hiển thị cảnh báo khi các giá trị suy nghĩ bị hạn chế hoặc điều chỉnh', + cliEnvOverride: 'Ghi đè CLI & Env', + supportLine1Prefix: 'Ngân sách suy nghĩ:', + supportLine1Suffix: '(dựa trên mã thông báo)', + supportLine2Prefix: 'Nỗ lực lý luận:', + supportLine2SuffixPrefix: '(hậu tố hoặc', + supportLine2SuffixPostfix: ': trung bình/cao/xcao)', + supportLine3Prefix: 'Nỗ lực ghim hậu tố Codex (ví dụ:', + supportLine3Suffix: '); các mô hình không có hậu tố sẽ sử dụng chế độ Suy nghĩ.', + }, + analytics: { + title: 'Phân tích', + subtitle: 'Theo dõi việc sử dụng và thông tin chi tiết', + month: 'Tháng', + allTime: 'mọi lúc', + updated: 'Đã cập nhật {{value}}', + }, + analyticsSummary: { + totalTokens: 'Tổng số token', + totalTokensSubtitle: '{{input}} vào / {{output}} ra', + totalCost: 'Tổng chi phí', + totalCostSubtitle: 'trung bình ${{value}}/ngày', + cacheTokens: 'Mã thông báo bộ đệm', + cacheTokensSubtitle: '${{cost}} ({{percent}}% chi phí)', + inputCost: 'Chi phí đầu vào', + outputCost: 'Chi phí đầu ra', + tokensSubtitle: '{{value}} mã thông báo', + }, + apiProfiles: { + title: 'Hồ sơ API', + new: 'Mới', + searchPlaceholder: 'Tìm kiếm hồ sơ...', + loadingProfiles: 'Đang tải hồ sơ...', + failedLoadTitle: 'Không tải được hồ sơ', + failedLoadDesc: 'Không thể tìm nạp hồ sơ API. Vui lòng thử lại.', + retry: 'Thử lại', + noProfilesYet: 'Chưa có hồ sơ API nào', + noProfilesDesc: 'Tạo hồ sơ đầu tiên của bạn để kết nối với các điểm cuối API tùy chỉnh', + createProfile: 'Tạo hồ sơ', + noProfileMatch: 'Không có hồ sơ nào khớp với "{{query}}"', + profileCount: '{{count}} hồ sơ', + profileCount_other: '{{count}} hồ sơ', + configuredCount: '{{count}} đã được định cấu hình', + deleteProfileTitle: 'Xóa hồ sơ', + deleteProfileDesc: + 'Bạn có chắc chắn muốn xóa "{{name}}" không? Thao tác này sẽ xóa tệp cài đặt và không thể hoàn tác.', + delete: 'Xóa bỏ', + unsavedChangesTitle: 'Những thay đổi chưa được lưu', + unsavedChangesDesc: + 'Bạn có những thay đổi chưa được lưu trong "{{current}}". Hủy và chuyển sang "{{next}}"?', + discardSwitch: 'Loại bỏ và chuyển đổi', + }, + accountsPage: { + title: 'Tài khoản', + managePrefix: 'Quản lý', + manageSuffix: 'tài khoản và giới thiệu nhóm từ một bảng điều khiển.', + mobileManageSuffix: 'tính liên tục cho mỗi tài khoản.', + primaryActions: 'Hành động chính', + createAccount: 'Tạo tài khoản', + authClaudeInPool: 'Xác thực Claude trong Pool', + openClaudePoolSettings: 'Mở cài đặt hồ bơi Claude', + openCliProxyClaudePool: 'Mở CLIProxy Claude Pool', + migrationFollowup: 'Theo dõi di chuyển', + legacyContextPending: '{{count}} tài khoản vẫn cần xác nhận chế độ lần đầu.', + legacyContextPending_other: '{{count}} tài khoản vẫn cần xác nhận chế độ lần đầu.', + legacyContinuityPending: + '{{count}} tài khoản được chia sẻ vẫn ở mức độ liên tục kế thừa tiêu chuẩn.', + legacyContinuityPending_other: + '{{count}} tài khoản được chia sẻ vẫn ở mức độ liên tục kế thừa tiêu chuẩn.', + confirmingLegacy: 'Đang xác nhận chính sách kế thừa...', + confirmLegacy: 'Xác nhận chính sách kế thừa ({{count}})', + noLegacyFollowup: 'Không có hoạt động theo dõi kế thừa nào đang chờ xử lý.', + continuityGuide: 'Hướng dẫn liên tục', + expandWhenNeeded: 'Chỉ mở rộng khi cần thiết.', + sharedStandard: 'Tiêu chuẩn chung', + sharedStandardDesc: + 'Chỉ đồng bộ hóa không gian làm việc của dự án. Mặc định tốt nhất cho hầu hết các đội.', + sharedDeeper: 'Chia sẻ sâu hơn', + sharedDeeperPrefix: 'Thêm', + isolated: 'Bị cô lập', + isolatedDesc: 'Không có liên kết. Tốt nhất cho sự tách biệt nghiêm ngặt.', + quickCommands: 'Lệnh nhanh', + quickCommandsDesc: 'Sao chép và chạy trong terminal.', + workspaceBadge: 'ccs auth Không gian làm việc', + historySyncBadge: 'Kiểm soát đồng bộ hóa lịch sử', + authAccounts: 'Tài khoản xác thực', + tableScopePrefix: 'Bảng này được cố ý đặt trong phạm vi', + tableScopeMiddle: 'tài khoản. Sử dụng', + tableScopeSuffix: 'để thay đổi chế độ/nhóm/độ sâu.', + accountMatrix: 'Ma trận tài khoản', + sharedTotalDesc: + 'Tổng số được chia sẻ: {{count}}. Các hành động bao gồm cài đặt Đồng bộ hóa và xác nhận kế thừa.', + loadingAccounts: 'Đang tải tài khoản...', + }, + health: { + justNow: 'ngay bây giờ', + secondsAgo: '{{count}} giây trước', + minutesAgo: '{{count}} phút trước', + hoursAgo: '{{count}} giờ trước', + systemHealth: 'Tình trạng hệ thống', + build: 'xây dựng {{version}}', + lastScan: 'Lần quét cuối cùng:', + autoRefresh: 'Tự động làm mới:', + refresh: 'Làm cho khỏe lại', + checks: 'Séc', + version: 'Phiên bản', + platform: 'Nền tảng', + liveMonitoring: 'Giám sát trực tiếp đang hoạt động', + copied: 'Đã sao chép vào bảng nhớ tạm', + refreshing: 'Kiểm tra sức khoẻ mới...', + fix: 'Sửa chữa', + applyFix: 'Áp dụng bản sửa lỗi', + healthLabel: 'sức khỏe', + }, + updates: { + actionRequired: 'Hành động bắt buộc', + done: 'Xong', + all: 'Tất cả', + inboxTitle: 'Hộp thư cập nhật', + inboxSubtitle: + 'Tập trung vào các hành động, sau đó đánh dấu các cập nhật đã hoàn tất hoặc bị loại bỏ.', + needsAction: 'Cần hành động', + doneCount: 'Xong', + searchPlaceholder: 'Tìm kiếm hành động hoặc lệnh', + noNotices: 'Không có thông báo phù hợp với quan điểm này.', + noUpdates: 'Không có bản cập nhật có sẵn.', + published: 'Đã xuất bản {{date}}', + markDone: 'Đánh dấu là xong', + dismiss: 'Miễn nhiệm', + reopen: 'Mở lại', + doNext: 'Làm tiếp theo', + impactedIntegrations: 'Tích hợp bị ảnh hưởng', + impactedDesc: 'Các khu vực liên quan dựa trên phạm vi cập nhật và định tuyến.', + whyMatters: 'Tại sao nó quan trọng', + whyMattersDesc: 'Chỉ ngữ cảnh ngắn, không có ghi chú phát hành trên tường văn bản.', + open: 'Mở', + progressNeedsAction: 'Cần hành động', + progressInReview: 'Đang xem xét', + progressDone: 'Xong', + progressDismissed: 'Bị loại bỏ', + statusNew: 'Mới', + statusStable: 'Ổn định', + statusPlanned: 'Đã lên kế hoạch', + dashboard: 'Trang tổng quan', + cliUsage: 'Cách sử dụng CLI', + }, + cliproxyPage: { + connected: 'Đã kết nối', + notConnected: 'Không được kết nối', + variant: 'khác nhau', + parentNotConnected: 'Phụ huynh không được kết nối', + viaProvider: 'qua {{provider}}', + emptyTitle: 'Trình quản lý hồ sơ CCS', + emptyDesc: + 'Quản lý xác thực OAuth, tùy chọn tài khoản và lựa chọn mô hình cho nhà cung cấp CLIProxy. Định cấu hình cách CCS định tuyến các yêu cầu đến các chương trình phụ trợ AI khác nhau.', + emptyControlPanelPrefix: + 'Để biết số liệu thống kê sử dụng trực tiếp và theo dõi thời gian thực, hãy truy cập', + controlPanel: 'Bảng điều khiển', + quickSetup: 'Thiết lập nhanh', + accountManagement: 'Quản lý tài khoản cấp CCS', + providers: 'Nhà cung cấp', + variants: 'Biến thể', + providerCount: '{{count}} nhà cung cấp', + providerCount_other: '{{count}} nhà cung cấp', + connectedCount: '{{count}} đã kết nối', + variantDisplay: '{{name}} (biến thể {{provider}})', + }, + copilotPage: { + title: 'Phi công phụ', + subtitle: 'Proxy GitHub Copilot', + setup: 'Cài đặt', + auth: 'Xác thực', + daemon: 'Daemon', + unofficialTitle: 'API không chính thức - Bạn phải tự chịu rủi ro khi sử dụng', + unofficialItem1: 'API được thiết kế ngược - có thể bị hỏng bất cứ lúc nào', + unofficialItem2: 'Việc sử dụng quá mức có thể gây ra hạn chế tài khoản', + unofficialItem3: 'Không bảo hành, không chịu trách nhiệm từ CCS', + yes: 'Đúng', + no: 'KHÔNG', + installed: 'Đã cài đặt', + missing: 'Mất tích', + integration: 'Tích hợp', + enabled: 'Đã bật', + disabled: 'Tàn tật', + github: 'GitHub', + connected: 'Đã kết nối', + notConnected: 'Chưa kết nối', + authenticate: 'Xác thực', + authenticating: 'Đang xác thực...', + status: 'Trạng thái', + running: 'Đang chạy', + stopped: 'Đã dừng', + port: 'Cảng', + stopping: 'Đang dừng...', + stop: 'Dừng lại', + starting: 'Đang bắt đầu...', + start: 'Bắt đầu', + installing: 'Đang cài đặt...', + installCopilotApi: 'Cài đặt copilot-api', + proxy: 'ủy quyền', + active: 'Tích cực', + inactive: 'Không hoạt động', + }, + cursorPage: { + title: 'Con trỏ', + beta: 'bản thử nghiệm', + subtitle: 'Điều khiển tích hợp con trỏ chuyên dụng', + unofficialTitle: 'API không chính thức - Bạn phải tự chịu rủi ro khi sử dụng', + unofficialItem1: 'Tích hợp thiết kế ngược có thể bị hỏng bất cứ lúc nào', + unofficialItem2: 'Lạm dụng hoặc sử dụng quá mức có thể có nguy cơ bị hạn chế tài khoản', + unofficialItem3: 'Không bảo hành, không chịu trách nhiệm từ CCS', + integration: 'Tích hợp', + authentication: 'Xác thực', + daemon: 'Daemon', + enabled: 'Đã bật', + disabled: 'Tàn tật', + expired: 'Hết hạn', + connected: 'Đã kết nối', + notConnected: 'Không được kết nối', + running: 'Đang chạy', + stopped: 'Đã dừng', + actions: 'hành động', + disableIntegration: 'Vô hiệu hóa tích hợp', + enableIntegration: 'Kích hoạt tích hợp', + autoDetectAuth: 'Tự động phát hiện xác thực', + manualAuthImport: 'Nhập xác thực thủ công', + stopDaemon: 'Dừng Daemon', + startDaemon: 'Khởi động Daemon', + port: 'Cảng', + configuration: 'Cấu hình con trỏ', + lastModified: 'Sửa đổi lần cuối:', + neverSaved: 'Chưa bao giờ lưu', + saving: 'Đang lưu...', + save: 'Cứu', + modelConfig: 'Cấu hình mô hình', + settings: 'Cài đặt', + info: 'Thông tin', + presets: 'cài đặt trước', + presetsDesc: 'Áp dụng ánh xạ mô hình được cấu hình sẵn', + modelMapping: 'Ánh xạ mô hình', + modelMappingDesc: 'Định cấu hình mô hình nào sẽ sử dụng cho từng tầng', + defaultModel: 'Mẫu mặc định', + defaultModelDesc: 'Được sử dụng khi không có yêu cầu cấp độ cụ thể.', + opusModel: 'Opus (Có khả năng nhất)', + opusModelDesc: 'Lý luận phức tạp và phản hồi chất lượng cao nhất.', + sonnetModel: 'Sonnet (Cân bằng)', + sonnetModelDesc: 'Khối lượng công việc mã hóa chung và trò chuyện hàng ngày.', + haikuModel: 'Haiku (Nhanh)', + haikuModelDesc: 'Đường dẫn yêu cầu có độ trễ thấp và nhẹ.', + runtimeSettings: 'Cài đặt thời gian chạy', + autoStartDaemon: 'Daemon tự động khởi động', + autoStartDesc: 'Tự động khởi động daemon con trỏ khi sử dụng tích hợp.', + ghostMode: 'Chế độ ma', + ghostModeDesc: 'Yêu cầu chế độ x-ghost để giảm việc đo từ xa.', + provider: 'nhà cung cấp', + filePath: 'Đường dẫn tệp', + availableModels: 'Các mẫu có sẵn', + loadingModels: 'Đang tải mô hình...', + noModels: 'Chưa có siêu dữ liệu mô hình nào. Bắt đầu daemon và làm mới.', + default: 'Mặc định', + rawConfiguration: 'Cấu hình thô (JSON)', + manualImportTitle: 'Nhập xác thực con trỏ thủ công', + manualImportDesc: + 'Cung cấp mã thông báo truy cập con trỏ và ID máy nếu tính năng tự động phát hiện không khả dụng.', + accessToken: 'Mã thông báo truy cập', + accessTokenPlaceholder: 'Dán mã thông báo truy cập con trỏ', + machineId: 'ID máy', + machineIdPlaceholder: 'Hex 32 ký tự (UUID không có dấu gạch nối)', + cancel: 'Hủy bỏ', + import: 'Nhập khẩu', + selectModel: 'Chọn mô hình', + useDefaultModel: 'Sử dụng mô hình mặc định', + availableModelCount: 'Các mẫu có sẵn ({{count}})', + invalidPort: 'Cổng phải là số nguyên từ 1 đến 65535', + defaultModelRequired: 'Mô hình mặc định là bắt buộc', + savedConfig: 'Đã lưu cấu hình con trỏ và ánh xạ mô hình', + failedSaveConfig: 'Không lưu được cấu hình', + modelsLoadingWait: + 'Các mô hình vẫn đang tải. Vui lòng đợi trước khi áp dụng cài đặt trước.', + noModelsAvailable: 'Chưa có mẫu nào. Khởi động daemon và làm mới trước.', + appliedCodexPreset: 'Đã áp dụng cài đặt trước Codex GPT-5.3', + appliedClaudePreset: 'Đã áp dụng cài đặt trước Claude 4.6', + appliedGeminiPreset: 'Đã áp dụng cài đặt trước Gemini 3', + integrationEnabled: 'Đã bật tích hợp con trỏ', + integrationDisabled: 'Tích hợp con trỏ bị vô hiệu hóa', + failedUpdateIntegration: 'Không cập nhật được trạng thái tích hợp', + credentialsImported: 'Thông tin xác thực con trỏ đã được nhập', + autoDetectFailed: 'Tự động phát hiện không thành công', + manualRequired: 'Cần có mã thông báo và ID máy', + manualImportFailed: 'Nhập thủ công không thành công', + failedStartDaemon: 'Không thể khởi động daemon', + daemonStarted: 'Daemon bắt đầu', + daemonStartedWithPid: 'Daemon đã khởi động (PID: {{pid}})', + failedStopDaemon: 'Không thể dừng daemon', + daemonStopped: 'Daemon dừng lại', + rawLoading: 'Cài đặt thô vẫn đang tải. Vui lòng đợi và thử lại.', + invalidJson: 'JSON không hợp lệ', + rawSaved: 'Đã lưu cài đặt thô', + rawChanged: 'Cài đặt thô đã thay đổi bên ngoài. Làm mới và thử lại.', + failedSaveRaw: 'Không lưu được cài đặt thô', + savedAll: 'Đã lưu cấu hình con trỏ', + }, + droidPage: { + loadingDiagnostics: 'Đang tải chẩn đoán Droid...', + failedDiagnostics: 'Không tải được chẩn đoán Droid.', + overview: 'Tổng quan', + byok: 'BYOK', + docs: 'Tài liệu', + runtimeInstall: 'Thời gian chạy và cài đặt', + status: 'Trạng thái', + detected: 'Đã phát hiện', + notFound: 'Không tìm thấy', + detectionSource: 'Nguồn phát hiện', + binaryPath: 'Đường dẫn nhị phân', + installDirectory: 'Thư mục cài đặt', + version: 'Phiên bản', + overridePath: 'Ghi đè (CCS_DROID_PATH)', + configFiles: 'Tệp cấu hình', + path: 'Con đường', + resolved: 'Đã giải quyết', + size: 'Kích cỡ', + lastModified: 'Sửa đổi lần cuối', + parseWarning: 'Cảnh báo phân tích cú pháp: {{value}}', + readWarning: 'Đọc cảnh báo: {{value}}', + warnings: 'Cảnh báo', + byokSummary: 'BYOK Tóm tắt', + activeModelSelector: 'Bộ chọn mô hình hoạt động', + customModels: 'Mô hình tùy chỉnh', + ccsManaged: 'do CCS quản lý', + userManaged: 'Do người dùng quản lý', + malformedEntries: 'Mục nhập không đúng định dạng', + providers: 'Nhà cung cấp', + none: 'không có', + customModelsTitle: 'Mô hình tùy chỉnh', + modelName: 'Tên/Mẫu mã', + provider: 'nhà cung cấp', + baseUrl: 'URL cơ sở', + noCustomModels: 'Không có mô hình tùy chỉnh', + docsAlignedNotes: 'Ghi chú được căn chỉnh trong Tài liệu', + factoryDocs: 'Tài liệu nhà máy', + providerFactCheckDocs: 'Tài liệu kiểm tra thực tế của nhà cung cấp', + providerValues: 'Giá trị của nhà cung cấp: {{value}}', + settingsHierarchy: 'Phân cấp cài đặt: {{value}}', + settingsTitle: 'Cài đặt BYOK của Droid', + fixJsonBeforeQuickSettings: + 'Sửa cú pháp JSON trước khi sử dụng các điều khiển cài đặt nhanh.', + fixJsonBeforeReasoning: 'Sửa cú pháp JSON trước khi cập nhật cài đặt lý luận BYOK.', + unableUpdateReasoning: 'Không thể cập nhật cài đặt lý luận mô hình BYOK đã chọn.', + fixJsonBeforeBudget: 'Sửa cú pháp JSON trước khi cập nhật ngân sách tư duy.', + anthropicOnlyBudget: 'Ngân sách tư duy chỉ có sẵn cho các mô hình BYOK của Anthropic.', + invalidJson: 'JSON không hợp lệ: {{value}}', + saved: 'Đã lưu cài đặt Droid', + changedExternally: 'Cài đặt Droid đã thay đổi bên ngoài. Làm mới và thử lại.', + failedSave: 'Không lưu được cài đặt Droid', + }, + sharedPage: { + commands: 'Lệnh', + skills: 'Kỹ năng', + agents: 'Đại lý', + title: 'Dữ liệu được chia sẻ', + subtitle: 'Các lệnh, kỹ năng và đặc vụ được chia sẻ trên các phiên bản của Claude', + totalShared: 'Tổng số được chia sẻ', + visible: 'Dễ thấy', + markdownDetail: 'Chế độ xem chi tiết đánh dấu', + filterPrefix: 'Lọc:', + configurationRequired: 'Yêu cầu cấu hình', + countsUnavailable: 'Số lượng không có sẵn', + retryCounts: 'Số lần thử lại', + filterPlaceholder: 'Lọc {{tab}} theo tên, mô tả hoặc đường dẫn', + showing: 'Đang hiển thị {{visible}} trong số {{total}} {{tab}}', + showingQuery: 'cho "{{query}}"', + refreshing: '(mới mẻ...)', + loadingShared: 'Đang tải chia sẻ {{tab}}...', + failedLoadShared: 'Không tải được chia sẻ {{tab}}', + retry: 'Thử lại', + noSharedFound: + 'Không tìm thấy {{tab}} được chia sẻ. Chạy `ccs sync` hoặc thêm các mục trong thư mục dùng chung của bạn.', + noMatch: 'Không có {{tab}} khớp với "{{query}}".', + selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.', + pathLabel: 'Con đường', + resolvedSource: 'Nguồn đã giải quyết', + loadingMarkdown: 'Đang tải nội dung đánh dấu...', + failedLoadContent: 'Không tải được nội dung', + retryContent: 'Thử lại nội dung', + noMarkdown: 'Không có nội dung đánh dấu có sẵn.', + }, + }, + }, } as const; if (!i18n.isInitialized) { diff --git a/ui/src/lib/locales.ts b/ui/src/lib/locales.ts index bf6ff60d..ca719231 100644 --- a/ui/src/lib/locales.ts +++ b/ui/src/lib/locales.ts @@ -1,6 +1,6 @@ export const LOCALE_STORAGE_KEY = 'ccs-ui-locale'; -export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const; +export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi'] as const; export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; @@ -11,6 +11,7 @@ export function isSupportedLocale(locale: string): locale is AppLocale { export function normalizeLocale(locale: string | null | undefined): AppLocale { if (!locale) return 'en'; if (locale.toLowerCase().startsWith('zh')) return 'zh-CN'; + if (locale.toLowerCase().startsWith('vi')) return 'vi'; return 'en'; } diff --git a/ui/tests/unit/ui/i18n/language-switcher.test.tsx b/ui/tests/unit/ui/i18n/language-switcher.test.tsx index 75f375d6..80f6ced7 100644 --- a/ui/tests/unit/ui/i18n/language-switcher.test.tsx +++ b/ui/tests/unit/ui/i18n/language-switcher.test.tsx @@ -3,7 +3,31 @@ import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; import i18n from '@/lib/i18n'; import { LanguageSwitcher } from '@/components/layout/language-switcher'; import { TabNavigation } from '@/pages/settings/components/tab-navigation'; -import { getInitialLocale, LOCALE_STORAGE_KEY, persistLocale } from '@/lib/locales'; +import { + getInitialLocale, + LOCALE_STORAGE_KEY, + normalizeLocale, + persistLocale, +} from '@/lib/locales'; + +function flattenKeys(node: unknown, prefix = '', out = new Set()): Set { + if (typeof node !== 'object' || node === null) return out; + for (const [key, value] of Object.entries(node as Record)) { + const nextKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') { + out.add(nextKey); + continue; + } + if (value && typeof value === 'object') { + flattenKeys(value, nextKey, out); + } + } + return out; +} + +function collectPlaceholders(text: string): string[] { + return Array.from(text.matchAll(/\{\{[^{}]+\}\}/g), (match) => match[0]).sort(); +} describe('Dashboard i18n', () => { const storage = new Map(); @@ -56,6 +80,7 @@ describe('Dashboard i18n', () => { expect(screen.getByRole('combobox')).toBeInTheDocument(); expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.queryByText('Vietnamese')).not.toBeInTheDocument(); await userEvent.click(screen.getByRole('combobox')); await userEvent.click(await screen.findByText('Simplified Chinese')); @@ -66,9 +91,22 @@ describe('Dashboard i18n', () => { expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN'); }, 10000); + it('supports Vietnamese locale in switcher and persistence', async () => { + render(); + + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(await screen.findByText('Vietnamese')); + + await waitFor(() => { + expect(i18n.language).toBe('vi'); + }); + expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'vi'); + expect(normalizeLocale('vi-VN')).toBe('vi'); + }); + it('restores locale from persisted storage', () => { - persistLocale('zh-CN'); - expect(getInitialLocale()).toBe('zh-CN'); + persistLocale('vi'); + expect(getInitialLocale()).toBe('vi'); }); it('shows Chinese labels on translated settings tabs', async () => { @@ -80,4 +118,33 @@ describe('Dashboard i18n', () => { expect(screen.getByText('环境')).toBeInTheDocument(); expect(screen.getByText('认证')).toBeInTheDocument(); }); + + it('keeps vi translation keys in parity with en and preserves placeholders', () => { + const resources = i18n.options.resources as + | Record }> + | undefined; + + const enTranslation = resources?.en?.translation; + const viTranslation = resources?.vi?.translation; + + expect(enTranslation).toBeDefined(); + expect(viTranslation).toBeDefined(); + + const enKeys = flattenKeys(enTranslation); + const viKeys = flattenKeys(viTranslation); + + expect([...viKeys].sort()).toEqual([...enKeys].sort()); + + for (const key of enKeys) { + const enValue = key + .split('.') + .reduce((acc, part) => (acc as Record)?.[part], enTranslation); + const viValue = key + .split('.') + .reduce((acc, part) => (acc as Record)?.[part], viTranslation); + + if (typeof enValue !== 'string' || typeof viValue !== 'string') continue; + expect(collectPlaceholders(viValue)).toEqual(collectPlaceholders(enValue)); + } + }); }); diff --git a/ui/tests/unit/ui/lib/error-log-parser.test.ts b/ui/tests/unit/ui/lib/error-log-parser.test.ts new file mode 100644 index 00000000..7c9a913a --- /dev/null +++ b/ui/tests/unit/ui/lib/error-log-parser.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + formatQuotaResetTimestamp, + formatRelativeTime, + getErrorTypeLabel, +} from '@/lib/error-log-parser'; + +describe('error-log-parser locale formatting', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('formats relative time in Vietnamese', () => { + const nowSeconds = Math.floor(Date.now() / 1000); + + expect(formatRelativeTime(nowSeconds - 10, 'vi')).toBe('vừa xong'); + expect(formatRelativeTime(nowSeconds - 120, 'vi')).toBe('2 phút trước'); + expect(formatRelativeTime(nowSeconds - 7200, 'vi')).toBe('2 giờ trước'); + }); + + it('returns localized error labels for Vietnamese', () => { + expect(getErrorTypeLabel('rate_limit', 'vi')).toBe('Quá giới hạn'); + expect(getErrorTypeLabel('auth', 'vi')).toBe('Lỗi xác thực'); + expect(getErrorTypeLabel('unknown', 'vi')).toBe('Lỗi'); + }); + + it('formats quota reset timestamp in Vietnamese', () => { + expect(formatQuotaResetTimestamp('2026-01-01T00:00:45.000Z', 'vi')).toBe('45 giây'); + expect(formatQuotaResetTimestamp('2026-01-01T00:05:00.000Z', 'vi')).toBe('5 phút'); + expect(formatQuotaResetTimestamp('2026-01-01T02:05:00.000Z', 'vi')).toBe('2 giờ 5 phút'); + expect(formatQuotaResetTimestamp('2025-12-31T23:59:58.000Z', 'vi')).toBe('bây giờ'); + }); +});