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..4585d5f1 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,1157 @@ 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: 'Chung', + 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: 'GitHub Copilot', + cursorIde: 'Cursor IDE', + accounts: 'Tài khoản', + allAccounts: 'Tất cả tài khoản', + sharedData: 'Dữ liệu dùng chung', + compatibleClis: 'CLI tương thích', + factoryDroid: 'Factory 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: 'Cần 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 preset 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 preset để lưu', + codeCopied: 'Đã sao chép mã vào bảng nhớ tạm', + copyCodeFailed: 'Không sao chép được mã', + appliedModelAllTiers: 'Đã áp dụng mô hình cho tất cả các tier', + 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: 'my-api', + 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: 'Ánh xạ mô hình (Opus/Sonnet/Haiku)', + modelMappingDesc: + 'Cấu hình ID mô hình riêng cho từng tier. Hữu ích với API proxy định tuyến từng loại mô hình đến backend 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', + }, + modelTierMapping: { + title: 'Ánh xạ tầng mô hình', + advanced: 'Nâng 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: 'vd: anthropic/claude-opus-4', + sonnetPlaceholder: 'ví dụ: anthropic/claude-sonnet-4', + haikuPlaceholder: 'vd: anthropic/claude-3.5-haiku', + footer: + 'Các biến này thiết lập ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_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 biến bằng ô nhập 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: 'Mô hình', + modelPlaceholder: 'ví dụ: gemini-2.5-pro, claude-opus-4-5', + choosePresetInstead: 'Hoặc chọn từ preset có sẵn', + selectModel: 'Chọn mô hình', + customModelName: 'Tên mô hình tùy chỉnh...', + enterAnyModel: 'Nhập bất kỳ tên mô hình nào được CLIProxy hỗ trợ', + defaultModel: 'Mặc định: {{model}}', + providerDefault: 'mặc định theo nhà cung cấp', + back: 'Quay lại', + skip: 'Bỏ qua', + 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: 'Preset tùy chỉnh', + presetNameOptional: 'Tên preset (tùy chọn)', + presetNamePlaceholder: 'ví dụ: Preset 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ô hình mặc định', + defaultModelDesc: 'Dùng khi không yêu cầu cấp mô hình 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 preset', + applyPreset: 'Áp dụng cài sẵn', + }, + componentModelSelector: { + selectModel: 'Chọn mô hình', + availableModelsCount: 'Mô hình khả dụng ({{count}})', + preview: 'Xem trước', + }, + copilotSettings: { + enableCopilot: 'Bật Copilot', + 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: 'Business', + accountTypeEnterprise: 'Enterprise', + 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ô hình nào cho nhà cung cấp này', + broken: 'BROKEN', + deprecated: 'DEPRECATED', + paid: 'PAID', + freeTier: 'Gói miễn phí', + paidTier: 'Gói 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: 'Đề xuất', + allModelsCount: 'Tất cả mô hình ({{count}})', + noModelsAvailable: 'Không có mô hình khả dụng', + }, + 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: 'default, sprint-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ộ cho session-env, file-history, shell-snapshots và todos. Thông tin xác thực vẫn tách biệt.', + 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: 'Lệnh', + 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: + 'Muốn dùng định tuyến Claude OAuth dạng pool? Dùng CLIProxy Claude pool từ nút hành động ở trang Tài khoản.', + close: 'Đóng', + copied: 'Đã sao chép!', + copyCommand: 'Sao chép lệnh', + }, + cliproxyModelCategory: { + google: 'Google (Gemini)', + openai: 'OpenAI (GPT)', + anthropic: 'Anthropic (Claude)', + antigravity: 'Antigravity', + other: 'Khác', + }, + cliproxyOverviewComponents: { + availableModels: 'Mô hình khả dụng', + noModelsAvailable: 'Không có mô hình khả dụng', + startSessionHint: 'Bắt đầu phiên CLIProxy để tải danh sách mô hình khả dụng', + totalCount: 'tổng cộng {{count}}', + availableThroughGroupedByProvider: + 'Các mô hình khả dụng qua {{backendLabel}}, được nhóm theo nhà cung cấp', + moreCount: '+{{count}} thêm', + }, + categorizedModelSelector: { + selectModel: 'Chọn mô hình', + noModelsAvailable: 'Không có mô hình khả dụng', + modelPlaceholder: 'Mô hình...', + }, + openrouterQuickStart: { + recommended: 'Đề xuất', + 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 cho mọi 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: 'vừa xong', + 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: 'Đang hoạt động', + 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 cấu hình.', + details: 'Chi tiết', + syncNow: 'Đồng bộ ngay', + }, + 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ó profile nào để đồng bộ.', + createProfilesFirst: 'Trước tiên hãy tạo hồ sơ API bằng tab Hồ sơ.', + modelLabel: 'Mô hình:', + 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ộ ngay', + }, + errorLogs: { + title: 'Nhật ký lỗi', + failedRequests_one: '{{count}} yêu cầu thất bại', + failedRequests_other: '{{count}} yêu cầu thất bại', + 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: 'Yêu cầu', + tabResponse: 'Phản hồi', + tabRaw: 'Raw', + modelLabel: 'Mô hình:', + quotaResetsIn: 'Quota sẽ được đặt lại sau', + method: 'Phương thức', + 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ó nội dung phản hồi', + rateLimitedMsg: + 'Bị giới hạn tốc độ. Hãy chuyển sang tài khoản khác hoặc chờ reset hạn ngạch. Mô hình {{model}} đã hết quota.', + rateLimitedMsgNoModel: + 'Bị giới hạn tốc độ. Hãy chuyển sang tài khoản khác hoặc chờ reset 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ừ upstream. 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 timeout.', + 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: 'tách biệt (không đồng bộ)', + sharedOption: 'chia sẻ (đã bật đồng bộ)', + 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ỉ đồng bộ project)', + 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: 'Lưu', + saving: 'Đang lưu...', + }, + historySyncLearningMap: { + title: 'Cách đồng bộ hóa lịch sử hoạt động', + description: + 'Tách biệt -> Chia sẻ -> Sâu hơn. Dùng Sync trên từng dòng để áp dụng thay đổi.', + learningMap: 'Bản đồ học tập', + isolated: 'Tách biệt', + 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 nghĩa là chia sẻ cùng bối cảnh project. Nếu để trống sẽ dùng nhóm mặc định.', + }, + accountsTable: { + name: 'Tên', + type: 'Kiểu', + defaultBadge: 'mặc định', + created: 'Ngày 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: 'Đang hoạt động', + 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', + sharedGroupStandard: 'chia sẻ ({{group}}, tiêu chuẩn)', + sharedGroupDeeper: 'chia sẻ ({{group}}, sâu hơn)', + sharedGroupLegacy: 'chia sẻ ({{group}}, kế thừa tiêu chuẩn)', + isolatedLegacy: 'tách biệt (mặc định cũ)', + isolated: 'tách biệt', + }, + 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ế độ power user', + 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: + 'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).', + 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 token từ Kiro IDE...', + authenticate: 'Xác thực', + importFromIde: 'Nhập từ IDE', + importing: 'Đang nhập...', + }, + 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: 'Thông tin quota chưa khả dụng', + reauthNeeded: 'Cần xác thực lại', + 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 Antigravity', + 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: + 'Issue #509 ghi nhận các trường hợp bị đình chỉ/cấm liên quan đến ccs agy và việc dùng chung tài khoản 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: 'Mô hình', + 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: 'Opus', + sonnet: 'Sonnet', + haiku: 'Haiku', + claudeCode: 'Claude Code', + factoryDroid: 'Factory Droid', + cancel: 'Hủy bỏ', + create: 'Tạo', + creating: 'Đang tạo...', + saveChanges: 'Lưu thay đổi', + saving: 'Đang lưu...', + placeholderName: 'Gemini của tôi', + placeholderModel: 'gemini-2.5-pro', + placeholderComposite: 'my-composite', + placeholderModelId: 'model-id', + placeholderAccountId: 'account-id', + editCompositeTitle: 'Chỉnh sửa biến thể tổng hợp: {{name}}', + editSingleTitle: 'Chỉnh sửa 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: 'Tùy chỉnh', + }, + 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 mớ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: 'Tư duy', + proxy: 'Proxy', + auth: 'Xác thực', + backup: 'Sao lưu', + }, + settingsAuth: { + failedFetchTokens: 'Không thể tải token xác thực', + failedSaveTokens: 'Không thể lưu token', + tokensUpdated: 'Đã cập nhật token. 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 token', + tokensReset: 'Token đã được đặt lại về mặc định. Khởi động lại CLIProxy để áp dụng.', + description: + 'Cấu hình token xác thực CLIProxy. Các thay đổi yêu cầu khởi động lại CLIProxy.', + apiKey: 'Khóa API', + custom: 'Tùy chỉnh', + apiKeyDesc: 'Được Claude Code dùng để xác thực qua 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: 'Lưu', + }, + settingsGlobalEnv: { + description: + 'Biến môi trường được áp dụng cho tất cả hồ sơ không dùng Claude subscription (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: 'Biến Env sẽ được áp dụng cho các hồ sơ bên thứ ba', + disabledDesc: 'Biến Env sẽ không được áp dụng', + envVars: 'Biến môi trường', + noneConfigured: 'Không có biến môi trường nào được cấu hình', + addNew: 'Thêm biến mới', + keyName: 'KEY_NAME', + value: 'giá trị', + add: 'Thêm', + quickAdd: 'Thêm nhanh các biến phổ biến', + allConfigured: 'Tất cả biến phổ biến đã đượ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 cấu hình', + noneConfiguredHint: 'Cài công cụ CLI để bật các nhà cung cấp WebSearch', + checkProviders: 'Kiểm tra các nhà cung cấp', + installed: 'đã cài đặt', + notInstalled: 'chưa được cài đặt', + model: 'Mô hình:', + howInstall: 'Cách cài đặt {{name}} CLI', + installGlobally: 'Cài đặt trên toàn cầu', + requiresKey: '(cần khóa API xAI)', + freeTier: '(Có sẵn cấp MIỄN PHÍ)', + viewDocs: 'Xem tài liệu', + }, + settingsProxy: { + failedLoadAgyMode: 'Không thể tải chế độ power user AGY', + failedUpdateAgyMode: 'Không thể cập nhật chế độ power user AGY', + failedVerifyAgyMode: 'Không thể xác minh trạng thái lưu của chế độ power user AGY', + notPersistedAgyMode: + 'Chế độ power user AGY chưa được lưu bền vững. Cấu hình có thể đã bị tiến trình khác sửa.', + agyModeEnabled: 'Đã bật chế độ power user AGY.', + agyModeDisabled: 'Đã tắt chế độ power user AGY.', + 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: 'Cục bộ', + remote: 'Từ 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: 'Tệp thực thi backend', + stopProxyToSwitch: + 'Dừng proxy đang chạy trong Trạng thái phiên bản trước khi chuyển backend.', + default: 'Mặc định', + plusDesc: 'Hỗ trợ đầy đủ nhà cung cấp, bao gồm Kiro và GitHub Copilot', + originalDesc: 'Binary 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: 'An toàn', + agyModeTitle: 'Chế độ power user Antigravity', + 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ỉ bật nếu bạn hiểu rõ rủi ro bị đình chỉ/cấm OAuth (#509). CCS không chịu trách nhiệm khi mất tài khoản.', + finalConfirm: '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: 'Nhập', + typeSuffix: 'để kích hoạt.', + typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ power user Antigravity', + exactPhrase: 'Phải nhập chính xác cụm từ.', + enableAgyMode: 'Bật chế độ power user', + toggleAgyMode: 'Chuyển đổi chế độ power user 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: 'Nâng 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: 'Host', + hostPlaceholder: '192.168.1.100 hoặc proxy.example.com', + defaultPort: 'mặc định {{value}}', + portPlaceholder: 'Để trống để dùng {{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: 'Dùng cho các yêu cầu API đến /v1/chat/completions', + managementKeyOptional: 'Khóa quản lý (tùy chọn)', + managementKeyPlaceholder: 'Đối với điểm cuối /v0/management/*', + managementKeyDesc: 'Dùng cho API quản lý dashboard. Nếu bỏ trống sẽ dùng Khóa API.', + 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ế độ Thinking', + 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 token)', + supportLine2Prefix: 'Nỗ lực lý luận:', + supportLine2SuffixPrefix: '(hậu tố hoặc ', + supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ', + supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.', + }, + 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: 'Tất cả thời gian', + 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: 'Token bộ đệm', + cacheTokensSubtitle: '${{cost}} ({{percent}}% chi phí)', + inputCost: 'Chi phí đầu vào', + outputCost: 'Chi phí đầu ra', + tokensSubtitle: '{{value}} token', + }, + 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ấ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', + 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à nhóm đồng bộ từ một nơi.', + 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 Claude pool', + openCliProxyClaudePool: 'Mở CLIProxy Claude Pool', + migrationFollowup: 'Theo dõi sau 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: 'Tách biệt', + 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: 'Không gian làm việc `ccs auth`', + 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ố tài khoản chia sẻ: {{count}}. Bao gồm thao tác Sync và xác nhận kế thừa.', + loadingAccounts: 'Đang tải tài khoản...', + }, + health: { + justNow: 'vừa xong', + 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: 'bản dựng {{version}}', + lastScan: 'Lần quét cuối cùng:', + autoRefresh: 'Tự động làm mới:', + refresh: 'Làm mới', + checks: 'Kiểm tra', + 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: 'Đang làm mới kiểm tra trạng thái...', + fix: 'Sửa chữa', + applyFix: 'Áp dụng bản sửa lỗi', + healthLabel: 'trạng thái', + }, + 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 nào khớp với chế độ xem này.', + noUpdates: 'Hiện không có bản cập nhật nào.', + published: 'Đã xuất bản {{date}}', + markDone: 'Đánh dấu hoàn tất', + dismiss: 'Ẩn', + 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: 'Chưa kết nối', + variant: 'Biến thể', + parentNotConnected: 'Profile gốc chưa 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à chọn mô hình cho nhà cung cấp CLIProxy. Cấu hình cách CCS định tuyến yêu cầu đến các backend 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: 'Copilot', + 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: 'Có', + no: 'Không', + installed: 'Đã cài đặt', + missing: 'Thiếu', + integration: 'Tích hợp', + enabled: 'Đã bật', + disabled: 'Đã 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: 'Proxy', + active: 'Đang hoạt động', + inactive: 'Không hoạt động', + }, + cursorPage: { + title: 'Cursor', + beta: 'Beta', + subtitle: 'Bảng điều khiển tích hợp Cursor', + 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ắt', + expired: 'Hết hạn', + connected: 'Đã kết nối', + notConnected: 'Chưa 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 Cursor', + lastModified: 'Sửa đổi lần cuối:', + neverSaved: 'Chưa bao giờ lưu', + saving: 'Đang lưu...', + save: 'Lưu', + modelConfig: 'Cấu hình mô hình', + settings: 'Cài đặt', + info: 'Thông tin', + presets: 'Cài đặt sẵn', + 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ô hình mặc định', + defaultModelDesc: 'Dùng khi không chỉ định tier 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 nhẹ, độ trễ thấp.', + runtimeSettings: 'Cài đặt thời gian chạy', + autoStartDaemon: 'Daemon tự động khởi động', + autoStartDesc: 'Tự động khởi động daemon Cursor khi dùng tích hợp.', + ghostMode: 'Chế độ ma', + ghostModeDesc: 'Gửi x-ghost-mode để giảm telemetry.', + provider: 'Nhà cung cấp', + filePath: 'Đường dẫn tệp', + availableModels: 'Mô hình khả dụng', + 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 Cursor thủ công', + manualImportDesc: + 'Cung cấp access token và machine ID của Cursor nếu không tự phát hiện được.', + accessToken: 'Access token', + accessTokenPlaceholder: 'Dán access token của Cursor', + 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', + selectModel: 'Chọn mô hình', + useDefaultModel: 'Sử dụng mô hình mặc định', + availableModelCount: 'Mô hình khả dụng ({{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 Cursor và ánh xạ mô hình', + failedSaveConfig: 'Không lưu được cấu hình', + modelsLoadingWait: 'Mô hình vẫn đang tải. Vui lòng đợi trước khi áp dụng preset.', + noModelsAvailable: 'Chưa có mô hình nào. Hãy khởi động daemon rồi làm mới.', + appliedCodexPreset: 'Đã áp dụng preset Codex GPT-5.3', + appliedClaudePreset: 'Đã áp dụng preset Claude 4.6', + appliedGeminiPreset: 'Đã áp dụng preset Gemini 3', + integrationEnabled: 'Đã bật tích hợp Cursor', + integrationDisabled: 'Đã tắt tích hợp Cursor', + failedUpdateIntegration: 'Không cập nhật được trạng thái tích hợp', + credentialsImported: 'Đã nhập thông tin xác thực Cursor', + autoDetectFailed: 'Tự động phát hiện không thành công', + manualRequired: 'Cần token và machine ID', + manualImportFailed: 'Nhập thủ công không thành công', + failedStartDaemon: 'Không thể khởi động daemon', + daemonStarted: 'Đã khởi động daemon', + daemonStartedWithPid: 'Daemon đã khởi động (PID: {{pid}})', + failedStopDaemon: 'Không thể dừng daemon', + daemonStopped: 'Đã dừng daemon', + 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 Cursor', + }, + 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: 'Đường dẫn', + 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: 'Tóm tắt BYOK', + 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/model', + 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 Factory', + 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: 'Agent', + title: 'Dữ liệu dùng chung', + subtitle: 'Các lệnh, kỹ năng và agent được chia sẻ giữa các phiên Claude', + totalShared: 'Tổng số mục dùng chung', + visible: 'Hiển thị', + markdownDetail: 'Chế độ xem chi tiết Markdown', + filterPrefix: 'Lọc:', + configurationRequired: 'Cần cấu hình', + countsUnavailable: 'Không có số liệu', + 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: '(đang làm mới...)', + loadingShared: 'Đang tải {{tab}} dùng chung...', + failedLoadShared: 'Không tải được danh sách {{tab}} dùng chung', + retry: 'Thử lại', + noSharedFound: + 'Không tìm thấy {{tab}} dùng chung. Chạy `ccs sync` hoặc thêm 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: 'Đường dẫn', + resolvedSource: 'Nguồn đã giải quyết', + loadingMarkdown: 'Đang tải nội dung Markdown...', + failedLoadContent: 'Không tải được nội dung', + retryContent: 'Thử lại nội dung', + noMarkdown: 'Không có nội dung Markdown khả dụng.', + }, + }, + }, } 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ờ'); + }); +});