diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index b185244..6fbe672 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/dropdown-menu'; import { MoreVertical, Trash2, RefreshCw } from 'lucide-react'; import { formatDistanceToNow, type Locale } from 'date-fns'; -import { enUS, zhCN, ru } from 'date-fns/locale'; +import { enUS, zhCN, ru, vi } from 'date-fns/locale'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -22,6 +22,7 @@ const DATE_LOCALE_MAP: Record = { en: enUS, 'zh-CN': zhCN, ru: ru, + vi: vi, }; interface AccountCardProps { @@ -162,7 +163,7 @@ export const AccountCard: React.FC = ({ onClick={(e) => e.stopPropagation()} > - Open menu + {t('common.openMenu')} diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index ae46216..510b370 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -316,14 +316,16 @@ export function CloudAccountList() { if (isNumber(credits)) { toast({ title: t('cloud.toast.quotaRefreshed'), - description: `AI credits: $${credits.toFixed(2)}`, + description: t('cloud.toast.refreshCreditsAvailable', { + amount: credits.toFixed(2), + }), }); return; } toast({ title: t('cloud.toast.quotaRefreshed'), - description: 'AI credits not available for this refresh.', + description: t('cloud.toast.refreshCreditsUnavailable'), }); }, onError: () => toast({ title: t('cloud.toast.refreshFailed'), variant: 'destructive' }), @@ -483,7 +485,7 @@ export function CloudAccountList() { if (file.size > 5 * 1024 * 1024) { toast({ title: t('cloud.error.loadFailed'), - description: 'File size exceeds 5MB limit', + description: t('cloud.exportImport.fileTooLarge'), variant: 'destructive', }); return; @@ -496,10 +498,10 @@ export function CloudAccountList() { const content = event.target?.result as string; JSON.parse(content); setImportFileContent(content); - } catch (err) { + } catch { toast({ title: t('cloud.error.loadFailed'), - description: 'Invalid JSON file format', + description: t('cloud.exportImport.invalidJson'), variant: 'destructive', }); setImportFileName(''); @@ -509,7 +511,7 @@ export function CloudAccountList() { reader.onerror = () => { toast({ title: t('cloud.error.loadFailed'), - description: 'Failed to read file', + description: t('cloud.exportImport.readFileFailed'), variant: 'destructive', }); }; @@ -588,12 +590,15 @@ export function CloudAccountList() { if (failed === 0) { toast({ title: t('cloud.toast.quotaRefreshed'), - description: `Successfully refreshed ${successful} accounts.`, + description: t('cloud.toast.batchRefreshSuccess', { count: successful }), }); } else { toast({ - title: `Refresh completed with issues`, - description: `Refreshed ${successful} accounts, ${failed} failed.`, + title: t('cloud.toast.batchRefreshPartial.title'), + description: t('cloud.toast.batchRefreshPartial.description', { + successful, + failed, + }), variant: 'destructive', }); } @@ -614,12 +619,15 @@ export function CloudAccountList() { if (failed === 0) { toast({ title: t('cloud.toast.deleted'), - description: `Successfully deleted ${successful} accounts.`, + description: t('cloud.toast.batchDeleteSuccess', { count: successful }), }); } else { toast({ - title: `Deletion completed with issues`, - description: `Deleted ${successful} accounts, ${failed} failed.`, + title: t('cloud.toast.batchDeletePartial.title'), + description: t('cloud.toast.batchDeletePartial.description', { + successful, + failed, + }), variant: 'destructive', }); } diff --git a/src/components/IdentityProfileDialog.tsx b/src/components/IdentityProfileDialog.tsx index 97014cb..4bfc96e 100644 --- a/src/components/IdentityProfileDialog.tsx +++ b/src/components/IdentityProfileDialog.tsx @@ -1,4 +1,4 @@ -import { type JSX, useEffect, useMemo, useRef, useState } from 'react'; +import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Fingerprint, @@ -48,11 +48,14 @@ function formatError(error: unknown): string { return String(error); } -function renderProfile(profile?: DeviceProfile): JSX.Element { +function renderProfile( + profile: DeviceProfile | undefined, + t: ReturnType['t'], +): JSX.Element { if (!profile) { return (
- N/A + {t('common.notAvailable')}
); } @@ -95,41 +98,44 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]); const showLoadingPlaceholder = initialLoading && !snapshot; - const refreshProfiles = async (options?: { silent?: boolean }) => { - if (!account) { - setSnapshot(null); - return; - } - const silent = options?.silent === true; - if (silent) { - setRefreshing(true); - } else { - setInitialLoading(true); - } - try { - const result = await getCloudIdentityProfiles({ accountId: account.id }); - setSnapshot(result); - } catch (error) { - toast({ - title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }), - description: formatError(error), - variant: 'destructive', - }); - } finally { - if (silent) { - setRefreshing(false); - } else { - setInitialLoading(false); + const refreshProfiles = useCallback( + async (options?: { silent?: boolean }) => { + if (!account) { + setSnapshot(null); + return; } - } - }; + const silent = options?.silent === true; + if (silent) { + setRefreshing(true); + } else { + setInitialLoading(true); + } + try { + const result = await getCloudIdentityProfiles({ accountId: account.id }); + setSnapshot(result); + } catch (error) { + toast({ + title: t('cloud.toast.actionFailed'), + description: formatError(error), + variant: 'destructive', + }); + } finally { + if (silent) { + setRefreshing(false); + } else { + setInitialLoading(false); + } + } + }, + [account, t, toast], + ); useEffect(() => { if (!open) { return; } - refreshProfiles(); - }, [open, account?.id]); + void refreshProfiles(); + }, [open, refreshProfiles]); const runAction = async (key: string, action: () => Promise) => { if (actionLockRef.current) { @@ -142,7 +148,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP await refreshProfiles({ silent: true }); } catch (error) { toast({ - title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }), + title: t('cloud.toast.actionFailed'), description: formatError(error), variant: 'destructive', }); @@ -247,7 +253,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP {t('cloud.identity.previewTitle')} - {renderProfile(previewProfile)} + {renderProfile(previewProfile, t)}
) : ( - renderProfile(snapshot?.boundProfile) + renderProfile(snapshot?.boundProfile, t) )}
@@ -353,7 +359,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP ) : null} - {renderProfile(version.profile)} + {renderProfile(version.profile, t)}
- - {t('proxy.regenerateConfirm.title', 'Regenerate API Key?')} - - - {t( - 'proxy.regenerateConfirm.description', - 'This will invalidate the current API key immediately. Any applications using the old key will stop working.', - )} - + {t('proxy.regenerateConfirm.title')} + {t('proxy.regenerateConfirm.description')} @@ -447,10 +427,8 @@ print(response.choices[0].message.content)`; {/* Auto Start Toggle */}
- -

- {t('proxy.config.auto_start_desc', 'Start proxy service when application launches')} -

+ +

{t('proxy.config.auto_start_desc')}

- {t('proxy.mapping.title', 'Model Mapping')} - - {t('proxy.mapping.description', 'Map Claude models to Gemini models for routing.')} - + {t('proxy.mapping.title')} + {t('proxy.mapping.description')}
@@ -481,7 +457,7 @@ print(response.choices[0].message.content)`;

- {t('proxy.mapping.maps_to', 'Maps to')} + {t('proxy.mapping.maps_to')}

- {t('proxy.mapping.restore', 'Restore Defaults')} + {t('proxy.mapping.restore')}
@@ -569,11 +545,9 @@ print(response.choices[0].message.content)`; - {t('proxy.examples.title', 'Usage Examples')} + {t('proxy.examples.title')} - - {t('proxy.examples.description', 'Example commands to call the local API proxy.')} - + {t('proxy.examples.description')} {/* Protocol Selector Cards */} @@ -588,7 +562,7 @@ print(response.choices[0].message.content)`; className={`h-2 w-2 rounded-full ${selectedProtocol === 'openai' ? 'animate-pulse bg-blue-500' : 'bg-blue-400'}`} > - OpenAI Protocol + {t('settings.examples.openai_protocol')}
@@ -597,7 +571,7 @@ print(response.choices[0].message.content)`;

- 💡 Cursor, Windsurf, NextChat + {t('settings.examples.openai_tools')}

@@ -611,7 +585,7 @@ print(response.choices[0].message.content)`; className={`h-2 w-2 rounded-full ${selectedProtocol === 'anthropic' ? 'animate-pulse bg-purple-500' : 'bg-purple-400'}`} > - Anthropic Protocol + {t('settings.examples.anthropic_protocol')}
@@ -619,7 +593,9 @@ print(response.choices[0].message.content)`; POST /v1/messages
-

💡 Claude Code CLI

+

+ {t('settings.examples.anthropic_tools')} +

@@ -649,7 +625,7 @@ print(response.choices[0].message.content)`; className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700" > {copied === 'curl' ? : } - {copied === 'curl' ? 'Copied!' : 'Copy'} + {copied === 'curl' ? t('proxy.copied') : t('proxy.copy')}
@@ -669,7 +645,7 @@ print(response.choices[0].message.content)`;
                 className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
               >
                 {copied === 'python' ?  : }
-                {copied === 'python' ? 'Copied!' : 'Copy'}
+                {copied === 'python' ? t('proxy.copied') : t('proxy.copy')}
               
             
             
diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx
index fbfdb13..217608f 100644
--- a/src/routes/settings.tsx
+++ b/src/routes/settings.tsx
@@ -84,9 +84,9 @@ function SettingsPage() {
 
       
         
-          {t('settings.general', 'General')}
-          {t('settings.models', 'Models')}
-          {t('settings.proxy_tab', 'Proxy')}
+          {t('settings.general')}
+          {t('settings.models')}
+          {t('settings.proxy_tab')}
         
 
         {/* --- GENERAL TAB --- */}
@@ -130,6 +130,7 @@ function SettingsPage() {
                     {t('settings.language.english')}
                     {t('settings.language.chinese')}
                     {t('settings.language.russian')}
+                    {t('settings.language.vietnamese')}
                   
                 
               
@@ -139,22 +140,15 @@ function SettingsPage() {
           {/* Account Settings Card */}
           
             
-              {t('settings.account.title', 'Account Settings')}
-              
-                {t('settings.account.description', 'Configure automatic account refresh and sync.')}
-              
+              {t('settings.account.title')}
+              {t('settings.account.description')}
             
             
               {/* Auto Refresh Quota */}
               
- -

- {t( - 'settings.account.auto_refresh_desc', - 'Periodically refresh quota info for all accounts', - )} -

+ +

{t('settings.account.auto_refresh_desc')}

- -

- {t( - 'settings.account.auto_sync_desc', - 'Periodically sync active account information', - )} -

+ +

{t('settings.account.auto_sync_desc')}

- {t('settings.startup.title', 'Startup')} - - {t( - 'settings.startup.description', - 'Control application launch behavior at system startup.', - )} - + {t('settings.startup.title')} + {t('settings.startup.description')}
- +

- {t( - 'settings.startup.auto_startup_desc', - 'Launch at sign-in and keep the app in the system tray', - )} + {t('settings.startup.auto_startup_desc')}

{isMac && (

- {t( - 'settings.startup.macos_hint', - 'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.', - )} + {t('settings.startup.macos_hint')}

)} @@ -240,10 +218,10 @@ function SettingsPage() {
{t('settings.version')}
-
{appVersion || 'Unknown'}
+
{appVersion || t('common.unknown')}
{t('settings.platform')}
-
{platform || 'Unknown'}
+
{platform || t('common.unknown')}
{t('settings.license')}
CC BY-NC-SA 4.0
@@ -254,7 +232,7 @@ function SettingsPage() { className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > - {t('settings.openLogDir', 'Open')} + {t('settings.openLogDir')}
@@ -263,23 +241,15 @@ function SettingsPage() { {/* Privacy & Error Reporting Card */} - {t('settings.privacy.title', 'Privacy')} - - {t( - 'settings.privacy.description', - 'Control how your data is used to improve the application.', - )} - + {t('settings.privacy.title')} + {t('settings.privacy.description')}
- +

- {t( - 'settings.privacy.error_reporting_desc', - 'Send anonymous error reports to help us improve the app. No personal data is collected.', - )} + {t('settings.privacy.error_reporting_desc')}

-

- {t( - 'settings.privacy.restart_note', - 'Changes to error reporting will take effect after restarting the application.', - )} -

+

{t('settings.privacy.restart_note')}

{/* Notifications Card */} - {t('settings.notifications.title', 'Notifications')} - - {t( - 'settings.notifications.description', - 'Configure desktop alerts for account events.', - )} - + {t('settings.notifications.title')} + {t('settings.notifications.description')}
- +

- {t( - 'settings.notifications.quotaAlertDesc', - 'Get notified when a model quota drops below the set threshold', - )} + {t('settings.notifications.quotaAlertDesc')}

- +

- {t( - 'settings.notifications.quotaThresholdDesc', - 'Percentage below which to trigger an alert', - )} + {t('settings.notifications.quotaThresholdDesc')}

@@ -363,10 +317,10 @@ function SettingsPage() { if (config) { try { await saveConfig({ ...config, quota_alert_threshold: parsed }); - } catch (err) { + } catch { toast({ title: t('common.error'), - description: 'Failed to save threshold setting', + description: t('settings.notifications.thresholdSaveFailed'), variant: 'destructive', }); } diff --git a/src/services/CloudMonitorService.ts b/src/services/CloudMonitorService.ts index 9de4abc..bce9fa7 100644 --- a/src/services/CloudMonitorService.ts +++ b/src/services/CloudMonitorService.ts @@ -5,6 +5,47 @@ import { AutoSwitchService } from './AutoSwitchService'; import { logger } from '../utils/logger'; import { classifyAccountStatusFromError } from '../utils/account-status'; +type CloudMonitorLanguage = 'en' | 'zh-CN' | 'ru' | 'vi'; + +const CLOUD_MONITOR_NOTIFICATION_TEXT: Record< + CloudMonitorLanguage, + { + lowQuotaTitle: string; + lowQuotaBody: (email: string, models: string) => string; + } +> = { + en: { + lowQuotaTitle: 'Low Quota Alert', + lowQuotaBody: (email, models) => `${email}: ${models} are low on quota`, + }, + 'zh-CN': { + lowQuotaTitle: '额度不足提醒', + lowQuotaBody: (email, models) => `${email}:${models} 的额度较低`, + }, + ru: { + lowQuotaTitle: 'Предупреждение о низкой квоте', + lowQuotaBody: (email, models) => `${email}: низкая квота у ${models}`, + }, + vi: { + lowQuotaTitle: 'Cảnh báo quota thấp', + lowQuotaBody: (email, models) => `${email}: ${models} đang có quota thấp`, + }, +}; + +function getCloudMonitorLanguage(language: string | null | undefined): CloudMonitorLanguage { + const normalizedLanguage = language?.toLowerCase() ?? 'en'; + if (normalizedLanguage.startsWith('zh')) { + return 'zh-CN'; + } + if (normalizedLanguage.startsWith('ru')) { + return 'ru'; + } + if (normalizedLanguage.startsWith('vi')) { + return 'vi'; + } + return 'en'; +} + function hasReusableCachedQuota(account: { quota?: { models?: Record }; }): boolean { @@ -131,7 +172,11 @@ export class CloudMonitorService { logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError); const classified = classifyAccountStatusFromError(refreshError); if (classified) { - await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason); + await CloudAccountRepo.setAccountStatus( + account.id, + classified.status, + classified.reason, + ); } continue; } @@ -163,7 +208,11 @@ export class CloudMonitorService { logger.error(`Monitor: Failed to update ${account.email}`, error); const classified = classifyAccountStatusFromError(error); if (classified) { - await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason); + await CloudAccountRepo.setAccountStatus( + account.id, + classified.status, + classified.reason, + ); if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) { logger.warn( `Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`, @@ -176,6 +225,10 @@ export class CloudMonitorService { // 4. Check for Quota Alerts const alertEnabled = CloudAccountRepo.getSetting('quota_alert_enabled', false); const alertThreshold = CloudAccountRepo.getSetting('quota_alert_threshold', 20); + const notificationLanguage = getCloudMonitorLanguage( + CloudAccountRepo.getSetting('language', 'en'), + ); + const notificationText = CLOUD_MONITOR_NOTIFICATION_TEXT[notificationLanguage]; if (alertEnabled) { for (const account of accounts) { @@ -188,8 +241,8 @@ export class CloudMonitorService { if (lowQuotaModels.length > 0) { new Notification({ - title: 'Low Quota Alert', - body: `${account.email}: ${lowQuotaModels.join(', ')} are low on quota`, + title: notificationText.lowQuotaTitle, + body: notificationText.lowQuotaBody(account.email, lowQuotaModels.join(', ')), silent: false, }).show(); } diff --git a/src/utils/installNotice.ts b/src/utils/installNotice.ts index 3750adb..6ffdad4 100644 --- a/src/utils/installNotice.ts +++ b/src/utils/installNotice.ts @@ -1,6 +1,6 @@ import path from 'path'; -export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru'; +export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi'; const installNoticeText: Record< InstallNoticeLanguage, @@ -32,6 +32,13 @@ const installNoticeText: Record< detailPrefix: 'Папка установки: ', buttons: ['Открыть папку', 'Понятно'], }, + vi: { + title: 'Hãy mở ứng dụng từ menu Start', + message: + 'Ứng dụng đang được chạy từ vị trí không phải thư mục cài đặt. Để tự động cập nhật hoạt động đúng, hãy mở ứng dụng từ menu Start hoặc biểu tượng ngoài màn hình. Nếu chưa có lối tắt, hãy chạy lại bộ cài.', + detailPrefix: 'Thư mục cài đặt: ', + buttons: ['Mở thư mục cài đặt', 'Đã hiểu'], + }, }; export function resolveInstallNoticeLanguage({ @@ -55,6 +62,10 @@ export function resolveInstallNoticeLanguage({ return 'ru'; } + if (normalized.startsWith('vi')) { + return 'vi'; + } + return 'en'; }