fix: remaining localization gaps (#156)

Co-authored-by: Lê Trung Hiếu <[email protected]>
This commit is contained in:
Lê Trung HiếuandLê Trung Hiếu authored and GitHub committed 2026-04-14 12:59:57 +08:00
1 parent 440a934280
commit 7093c982ba
13 files changed
+838 -208

No files matched your search

+3 -2
View File
@@ -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<string, Locale> = {
en: enUS,
'zh-CN': zhCN,
ru: ru,
vi: vi,
};
interface AccountCardProps {
@@ -162,7 +163,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('common.openMenu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
+20 -12
View File
@@ -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',
});
}
+45 -41
View File
@@ -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<typeof useTranslation>['t'],
): JSX.Element {
if (!profile) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
N/A
{t('common.notAvailable')}
</div>
);
}
@@ -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<void>) => {
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
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{renderProfile(previewProfile)}
{renderProfile(previewProfile, t)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
@@ -295,7 +301,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{t('cloud.identity.loading')}
</div>
) : (
renderProfile(snapshot?.currentStorage)
renderProfile(snapshot?.currentStorage, t)
)}
</CardContent>
</Card>
@@ -313,7 +319,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{t('cloud.identity.loading')}
</div>
) : (
renderProfile(snapshot?.boundProfile)
renderProfile(snapshot?.boundProfile, t)
)}
</CardContent>
</Card>
@@ -353,7 +359,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
) : null}
</div>
{renderProfile(version.profile)}
{renderProfile(version.profile, t)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
@@ -402,15 +408,13 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
<Card className="shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-sm">
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
</CardTitle>
<CardTitle className="text-sm">{t('cloud.identity.baseline')}</CardTitle>
</CardHeader>
<CardContent>
{showLoadingPlaceholder ? (
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
) : (
renderProfile(snapshot?.baseline)
renderProfile(snapshot?.baseline, t)
)}
</CardContent>
</Card>
+3 -1
View File
@@ -130,7 +130,9 @@ export function ModelVisibilitySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{t('settings.modelVisibility.title')}</span>
<Badge variant="secondary">{filteredModelIds.length} models</Badge>
<Badge variant="secondary">
{t('settings.providerGroupings.models', { count: filteredModelIds.length })}
</Badge>
</CardTitle>
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
</CardHeader>
+6 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/ui/use-toast';
import { ipc } from '@/ipc/manager';
@@ -9,6 +10,7 @@ import { AppConfig } from '@/types/config';
const SAVE_DEBOUNCE_MS = 400;
export function useAppConfig() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const {
@@ -57,8 +59,8 @@ export function useAppConfig() {
queryClient.setQueryData(['appConfig'], savedConfig);
lastStableConfigRef.current = savedConfig;
toast({
title: 'Settings saved',
description: 'Your configuration has been updated.',
title: t('settings.toast.saved.title'),
description: t('settings.toast.saved.description'),
});
for (const item of pendingBatch) {
item.resolve();
@@ -72,7 +74,7 @@ export function useAppConfig() {
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
}
toast({
title: 'Error saving settings',
title: t('settings.toast.saveFailed.title'),
description: error.message,
variant: 'destructive',
});
@@ -80,7 +82,7 @@ export function useAppConfig() {
item.reject(error);
}
}
}, [queryClient, updateConfig]);
}, [queryClient, t, updateConfig]);
const saveConfig = useCallback(
(newConfig: AppConfig) => {
+33 -3
View File
@@ -25,18 +25,48 @@ const en: TrayTexts = {
const zh: TrayTexts = {
current: '当前账号',
quota: '当前额度',
switch_next: '切换下一个账号',
switch_next: '切换到下一个账号',
refresh_current: '刷新当前额度',
show_window: '显示主窗口',
quit: '退出程序',
quit: '退出应用',
no_account: '无账号',
unknown_quota: '未知',
forbidden: '账号无权限',
forbidden: '账号已被禁用',
};
const ru: TrayTexts = {
current: 'Текущий',
quota: 'Квота',
switch_next: 'Переключить на следующий аккаунт',
refresh_current: 'Обновить текущую квоту',
show_window: 'Показать главное окно',
quit: 'Выйти из приложения',
no_account: 'Нет аккаунта',
unknown_quota: 'Неизвестно',
forbidden: 'Аккаунт заблокирован',
};
const vi: TrayTexts = {
current: 'Hiện tại',
quota: 'Quota',
switch_next: 'Chuyển sang tài khoản tiếp theo',
refresh_current: 'Làm mới quota hiện tại',
show_window: 'Hiện cửa sổ chính',
quit: 'Thoát ứng dụng',
no_account: 'Không có tài khoản',
unknown_quota: 'Không rõ',
forbidden: 'Tài khoản bị cấm',
};
export function getTrayTexts(lang: string = 'en'): TrayTexts {
if (lang.startsWith('zh')) {
return zh;
}
if (lang.startsWith('ru')) {
return ru;
}
if (lang.startsWith('vi')) {
return vi;
}
return en;
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const MainLayout: React.FC = () => {
{
to: '/proxy',
icon: Network,
label: t('nav.proxy', 'API Proxy'),
label: t('nav.proxy'),
},
{
to: '/settings',
+119 -1
View File
@@ -1,6 +1,7 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import vi from './vi';
i18n
.use(LanguageDetector)
@@ -12,12 +13,19 @@ i18n
caches: ['localStorage'],
lookupLocalStorage: 'lang',
},
supportedLngs: ['en', 'zh-CN', 'ru'],
supportedLngs: ['en', 'zh-CN', 'ru', 'vi'],
load: 'currentOnly', // Only load the exact language code, not variants
resources: {
en: {
translation: {
appName: 'Antigravity Manager',
common: {
loading: 'Loading...',
error: 'Error',
unknown: 'Unknown',
notAvailable: 'N/A',
openMenu: 'Open menu',
},
status: {
checking: 'Checking status...',
running: 'Antigravity is running in background',
@@ -84,6 +92,7 @@ i18n
english: 'English',
chinese: 'Chinese (Simplified)',
russian: 'Russian',
vietnamese: 'Vietnamese',
},
about: {
title: 'About',
@@ -93,6 +102,15 @@ i18n
platform: 'Platform',
license: 'License',
openLogDir: 'Open',
toast: {
saved: {
title: 'Settings saved',
description: 'Your configuration has been updated.',
},
saveFailed: {
title: 'Error saving settings',
},
},
account: {
title: 'Account Settings',
description: 'Configure automatic account refresh and sync.',
@@ -125,6 +143,8 @@ i18n
quotaAlertDesc: 'Get notified when a model quota drops below the set threshold',
quotaThreshold: 'Alert Threshold',
quotaThresholdDesc: 'Percentage below which to trigger an alert',
saveFailed: 'Failed to save notification settings',
thresholdSaveFailed: 'Failed to save threshold setting',
},
proxy: {
title: 'Upstream Proxy',
@@ -369,6 +389,20 @@ i18n
autoSwitchOn: 'Auto-Switch Enabled',
autoSwitchOff: 'Auto-Switch Disabled',
updateSettingsFailed: 'Failed to update settings',
actionFailed: 'Action failed',
startAuthFailed: 'Failed to start sign-in flow',
refreshCreditsAvailable: 'AI credits: ${{amount}}',
refreshCreditsUnavailable: 'AI credits not available for this refresh.',
batchRefreshSuccess: 'Successfully refreshed {{count}} accounts.',
batchRefreshPartial: {
title: 'Refresh completed with issues',
description: 'Refreshed {{successful}} accounts, {{failed}} failed.',
},
batchDeleteSuccess: 'Successfully deleted {{count}} accounts.',
batchDeletePartial: {
title: 'Deletion completed with issues',
description: 'Deleted {{successful}} accounts, {{failed}} failed.',
},
},
batch: {
selected: 'Selected {{count}}',
@@ -403,6 +437,9 @@ i18n
importErrors: 'Import completed with {{count}} error(s)',
selectFile: 'Select File',
importing: 'Importing...',
fileTooLarge: 'File size exceeds 5MB limit',
invalidJson: 'Invalid JSON file format',
readFileFailed: 'Failed to read file',
},
},
proxy: {
@@ -410,6 +447,7 @@ i18n
description: 'Manage the local API proxy service.',
save: 'Save Settings',
copy: 'Copy',
copied: 'Copied!',
regenerate: 'Regenerate',
regenerateConfirm: {
title: 'Regenerate API Key?',
@@ -433,6 +471,7 @@ i18n
auto_start: 'Auto Start with App',
auto_start_desc: 'Start proxy service when application launches',
local_access: 'Local network access:',
select_ip: 'Select IP',
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
show_key: 'Show',
hide_key: 'Hide',
@@ -450,9 +489,19 @@ i18n
},
},
},
vi: {
translation: vi,
},
'zh-CN': {
translation: {
appName: 'Antigravity 管理器',
common: {
loading: '加载中...',
error: '错误',
unknown: '未知',
notAvailable: '不适用',
openMenu: '打开菜单',
},
status: {
checking: '正在检查状态...',
running: 'Antigravity 正在后台运行',
@@ -519,6 +568,7 @@ i18n
english: '英语',
chinese: '中文 (简体)',
russian: '俄语',
vietnamese: '越南语',
},
about: {
title: '关于',
@@ -528,6 +578,15 @@ i18n
platform: '平台',
license: '许可证',
openLogDir: '打开',
toast: {
saved: {
title: '设置已保存',
description: '配置已更新。',
},
saveFailed: {
title: '保存设置失败',
},
},
account: {
title: '账号设置',
description: '配置自动刷新和同步账号。',
@@ -558,6 +617,8 @@ i18n
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
quotaThreshold: '提醒阈值',
quotaThresholdDesc: '触发提醒的百分比阈值',
saveFailed: '保存通知设置失败',
thresholdSaveFailed: '保存阈值设置失败',
},
proxy: {
title: '上游代理',
@@ -799,6 +860,20 @@ i18n
autoSwitchOn: '自动切换已启用',
autoSwitchOff: '自动切换已禁用',
updateSettingsFailed: '更新设置失败',
actionFailed: '操作失败',
startAuthFailed: '启动登录流程失败',
refreshCreditsAvailable: 'AI 积分:${{amount}}',
refreshCreditsUnavailable: '本次刷新没有可用的 AI 积分信息。',
batchRefreshSuccess: '成功刷新 {{count}} 个账号。',
batchRefreshPartial: {
title: '刷新已完成,但存在问题',
description: '已刷新 {{successful}} 个账号,{{failed}} 个失败。',
},
batchDeleteSuccess: '成功删除 {{count}} 个账号。',
batchDeletePartial: {
title: '删除已完成,但存在问题',
description: '已删除 {{successful}} 个账号,{{failed}} 个失败。',
},
},
batch: {
selected: '已选 {{count}} 项',
@@ -833,6 +908,9 @@ i18n
importErrors: '导入完成,但有 {{count}} 个错误',
selectFile: '选择文件',
importing: '导入中...',
fileTooLarge: '文件大小超过 5MB 限制',
invalidJson: 'JSON 文件格式无效',
readFileFailed: '读取文件失败',
},
},
proxy: {
@@ -840,6 +918,7 @@ i18n
description: '管理本地 API 代理服务。',
save: '保存设置',
copy: '复制',
copied: '已复制!',
regenerate: '重新生成',
regenerateConfirm: {
title: '重新生成 API 密钥?',
@@ -862,6 +941,7 @@ i18n
auto_start: '随应用启动',
auto_start_desc: '应用启动时自动启动代理服务',
local_access: '局域网访问:',
select_ip: '选择 IP',
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
show_key: '显示密钥',
hide_key: '隐藏密钥',
@@ -882,6 +962,13 @@ i18n
ru: {
translation: {
appName: 'Antigravity Manager',
common: {
loading: 'Загрузка...',
error: 'Ошибка',
unknown: 'Неизвестно',
notAvailable: 'Н/Д',
openMenu: 'Открыть меню',
},
status: {
checking: 'Проверка статуса...',
running: 'Antigravity работает в фоне',
@@ -949,6 +1036,7 @@ i18n
english: 'English',
chinese: '中文 (简体)',
russian: 'Русский',
vietnamese: 'Вьетнамский',
},
about: {
title: 'О программе',
@@ -958,6 +1046,15 @@ i18n
platform: 'Платформа',
license: 'Лицензия',
openLogDir: 'Открыть',
toast: {
saved: {
title: 'Настройки сохранены',
description: 'Конфигурация обновлена.',
},
saveFailed: {
title: 'Ошибка сохранения настроек',
},
},
account: {
title: 'Настройки аккаунта',
description: 'Настройка автоматического обновления и синхронизации.',
@@ -991,6 +1088,8 @@ i18n
'Получайте уведомления, когда квота модели падает ниже установленного порога',
quotaThreshold: 'Порог уведомления',
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
saveFailed: 'Не удалось сохранить настройки уведомлений',
thresholdSaveFailed: 'Не удалось сохранить порог уведомления',
},
proxy: {
title: 'Верхнеуровневый прокси',
@@ -1234,6 +1333,20 @@ i18n
autoSwitchOn: 'Авто-переключение включено',
autoSwitchOff: 'Авто-переключение выключено',
updateSettingsFailed: 'Не удалось обновить настройки',
actionFailed: 'Не удалось выполнить действие',
startAuthFailed: 'Не удалось запустить процесс входа',
refreshCreditsAvailable: 'AI кредиты: ${{amount}}',
refreshCreditsUnavailable: 'AI кредиты недоступны для этого обновления.',
batchRefreshSuccess: 'Успешно обновлено {{count}} аккаунтов.',
batchRefreshPartial: {
title: 'Обновление завершено с проблемами',
description: 'Обновлено {{successful}} аккаунтов, {{failed}} с ошибкой.',
},
batchDeleteSuccess: 'Успешно удалено {{count}} аккаунтов.',
batchDeletePartial: {
title: 'Удаление завершено с проблемами',
description: 'Удалено {{successful}} аккаунтов, {{failed}} с ошибкой.',
},
},
batch: {
selected: 'Выбрано {{count}}',
@@ -1269,6 +1382,9 @@ i18n
importErrors: 'Импорт завершён с {{count}} ошибкой(ами)',
selectFile: 'Выбрать файл',
importing: 'Импорт...',
fileTooLarge: 'Размер файла превышает лимит в 5 МБ',
invalidJson: 'Некорректный формат JSON-файла',
readFileFailed: 'Не удалось прочитать файл',
},
},
proxy: {
@@ -1276,6 +1392,7 @@ i18n
description: 'Управление локальным прокси-сервером API.',
save: 'Сохранить',
copy: 'Копировать',
copied: 'Скопировано!',
regenerate: 'Перегенерировать',
regenerateConfirm: {
title: 'Перегенерировать API ключ?',
@@ -1299,6 +1416,7 @@ i18n
auto_start: 'Автозапуск',
auto_start_desc: 'Запускать прокси при старте приложения',
local_access: 'Доступ из локальной сети:',
select_ip: 'Выбрать IP',
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
show_key: 'Показать',
hide_key: 'Скрыть',
+471
View File
@@ -0,0 +1,471 @@
const vi = {
appName: 'Antigravity Manager',
common: {
loading: 'Đang tải...',
error: 'Lỗi',
unknown: 'Không rõ',
notAvailable: 'Không có',
openMenu: 'Mở menu',
},
status: {
checking: 'Đang kiểm tra trạng thái...',
running: 'Antigravity đang chạy nền',
stopped: 'Dịch vụ Antigravity đã dừng',
},
action: {
stop: 'Dừng',
start: 'Bắt đầu',
switch: 'Chuyển',
deleteBackup: 'Xóa bản sao lưu',
backupCurrent: 'Sao lưu tài khoản hiện tại',
retry: 'Thử lại',
openLogs: 'Mở thư mục log',
},
error: {
generic: 'Đã xảy ra lỗi không mong đợi.',
keychainUnavailable: 'Keychain không khả dụng.',
keychainHint: {
translocation:
'Đã phát hiện macOS App Translocation. Hãy chuyển ứng dụng vào /Applications rồi mở lại.',
keychainDenied:
'Quyền truy cập Keychain đã bị từ chối. Ứng dụng có thể chưa được ký; hãy xem README để biết cách tự ký.',
signNotarize: 'Hãy ưu tiên dùng bản dựng đã được ký và công chứng khi có thể.',
},
dataMigrationFailed: 'Không thể giải mã dữ liệu tài khoản cũ.',
dataMigrationHint: {
relogin: 'Hãy đăng nhập lại hoặc thêm lại tài khoản của bạn.',
clearData: 'Nếu lỗi vẫn tiếp diễn, hãy xóa dữ liệu tài khoản cục bộ rồi đăng nhập lại.',
},
},
nav: {
accounts: 'Tài khoản',
proxy: 'API Proxy',
settings: 'Cài đặt',
},
account: {
current: 'Hiện tại',
lastUsed: 'Đã dùng {{time}}',
},
home: {
title: 'Tài khoản',
description: 'Quản lý các tài khoản Antigravity Google Gemini.',
noBackups: {
title: 'Chưa tìm thấy bản sao lưu',
description: 'Hãy tạo bản sao lưu cho tài khoản Antigravity hiện tại để bắt đầu.',
action: 'Sao lưu tài khoản hiện tại',
},
},
settings: {
title: 'Cài đặt',
description: 'Quản lý tùy chọn ứng dụng.',
general: 'Chung',
connection: 'Kết nối',
models: 'Mô hình',
appearance: {
title: 'Giao diện',
description: 'Tùy chỉnh giao diện Antigravity Manager trên thiết bị của bạn.',
},
darkMode: 'Chế độ tối',
darkModeDescription: 'Bật chế độ tối để dễ nhìn hơn vào ban đêm.',
language: {
title: 'Ngôn ngữ',
description: 'Chọn ngôn ngữ bạn muốn sử dụng.',
english: 'Tiếng Anh',
chinese: 'Tiếng Trung Giản thể',
russian: 'Tiếng Nga',
vietnamese: 'Tiếng Việt',
},
about: {
title: 'Thông tin',
description: 'Thông tin ứng dụng.',
},
version: 'Phiên bản',
platform: 'Nền tảng',
license: 'Giấy phép',
openLogDir: 'Mở',
toast: {
saved: {
title: 'Đã lưu cài đặt',
description: 'Cấu hình của bạn đã được cập nhật.',
},
saveFailed: {
title: 'Không thể lưu cài đặt',
},
},
account: {
title: 'Cài đặt tài khoản',
description: 'Cấu hình tự động làm mới quota và đồng bộ.',
auto_refresh: 'Tự động làm mới quota',
auto_refresh_desc: 'Định kỳ cập nhật thông tin quota cho tất cả tài khoản',
auto_sync: 'Tự động đồng bộ tài khoản hiện tại',
auto_sync_desc: 'Định kỳ đồng bộ thông tin tài khoản đang hoạt động',
},
startup: {
title: 'Khởi động',
description: 'Điều khiển hành vi khởi động cùng hệ thống.',
auto_startup: 'Khởi động cùng hệ thống',
auto_startup_desc: 'Chạy khi đăng nhập và ẩn vào khay hệ thống',
macos_hint:
'macOS cần bản ký ứng dụng để Login Items hoạt động. Nếu tự động khởi động thất bại, hãy ký ứng dụng hoặc bật thủ công trong System Settings.',
},
privacy: {
title: 'Quyền riêng tư',
description: 'Quản lý cách dữ liệu được sử dụng để cải thiện ứng dụng.',
error_reporting: 'Báo cáo lỗi',
error_reporting_desc:
'Gửi báo cáo lỗi ẩn danh để giúp cải thiện ứng dụng. Không thu thập dữ liệu cá nhân.',
restart_note: 'Thay đổi báo cáo lỗi sẽ có hiệu lực sau khi khởi động lại ứng dụng.',
},
notifications: {
title: 'Thông báo',
description: 'Cấu hình cảnh báo trên desktop cho các sự kiện tài khoản.',
quotaAlert: 'Cảnh báo quota thấp',
quotaAlertDesc: 'Nhận thông báo khi quota của model giảm xuống dưới ngưỡng đã đặt',
quotaThreshold: 'Ngưỡng cảnh báo',
quotaThresholdDesc: 'Phần trăm dùng để kích hoạt cảnh báo',
saveFailed: 'Không thể lưu cài đặt thông báo',
thresholdSaveFailed: 'Không thể lưu ngưỡng cảnh báo',
},
proxy: {
title: 'Proxy trung gian',
description: 'Cấu hình proxy cho các request đi ra Google/Gemini APIs.',
enable: 'Bật proxy trung gian',
url: 'Địa chỉ proxy',
timeout: 'Thời gian chờ request (giây)',
},
modelMapping: {
title: 'Ánh xạ mô hình',
description:
'Ánh xạ các model Claude Code sang model Antigravity để tối ưu chi phí và tốc độ.',
claudeKeyword: 'Từ khóa model Claude',
targetGemini: 'Model Gemini đích',
addPlaceholderKey: 'ví dụ: op-3',
addPlaceholderValue: 'ví dụ: gemini-3-flash',
noMappings: 'Chưa có ánh xạ tùy chỉnh.',
mapsTo: 'Ánh xạ tới',
default: 'Mặc định',
restoreDefaults: 'Khôi phục mặc định',
},
modelVisibility: {
title: 'Hiển thị mô hình',
description:
'Điều khiển model nào được hiện trong thẻ tài khoản. Model bị ẩn sẽ không xuất hiện ở phần quota.',
searchPlaceholder: 'Tìm mô hình...',
showAll: 'Hiện tất cả',
hideAll: 'Ẩn tất cả',
reset: 'Đặt lại mặc định',
save: 'Lưu thay đổi',
noModels: 'Không tìm thấy mô hình',
modelsShown: 'Đang hiện {{visible}} / {{total}} mô hình',
quotaManagement: 'Quản lý quota',
hidden: 'Đã ẩn',
noModelsFound: 'Không tìm thấy mô hình',
totalModels: 'Tổng',
visibleModels: 'Đang hiện',
hiddenModels: 'Đã ẩn',
saving: 'Đang lưu...',
},
providerGroupings: {
title: 'Nhóm theo nhà cung cấp',
description: 'Nhóm model theo nhà cung cấp để dễ quản lý hơn',
enabled: 'Bật nhóm theo nhà cung cấp',
models: '{{count}} mô hình',
avgLabel: 'tb',
resetLabel: 'reset',
overall: 'Tổng thể',
healthy: 'Tốt',
degraded: 'Giảm',
limited: 'Giới hạn',
critical: 'Nghiêm trọng',
},
examples: {
title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.',
curl: 'cURL',
python: 'Python',
copy: 'Sao chép',
copied: 'Đã sao chép!',
openai_protocol: 'Giao thức OpenAI',
anthropic_protocol: 'Giao thức Anthropic',
openai_tools: 'Cursor, Windsurf, NextChat',
anthropic_tools: 'Claude Code CLI',
flash: 'Nhanh',
pro: 'Pro',
flash_preview: 'Xem trước',
pro_high: 'Tốt nhất',
sonnet: 'Thinking',
opus: 'Opus',
},
gateway: {
title: 'Dịch vụ API Proxy',
description: 'Điều khiển local API proxy server.',
status_running: 'Đang chạy',
status_stopped: 'Đã dừng',
action_start: 'Bắt đầu dịch vụ',
action_stop: 'Dừng dịch vụ',
accounts_info: '{{count}} tài khoản khả dụng',
port: 'Cổng lắng nghe',
port_hint: 'Mặc định 8045, cần khởi động lại để áp dụng',
timeout: 'Thời gian chờ request',
timeout_hint: 'Mặc định 120 giây, khoảng 30-600 giây',
api_key: 'API Key',
regenerate_key: 'Tạo lại',
regenerateConfirm: {
title: 'Tạo lại API Key?',
description:
'API key hiện tại sẽ hết hiệu lực ngay lập tức. Các ứng dụng đang dùng key cũ sẽ ngừng hoạt động.',
cancel: 'Hủy',
confirm: 'Tạo lại',
},
key_warning: 'Hãy giữ kín API key của bạn. Không chia sẻ với người khác.',
auto_start: 'Tự động chạy cùng app',
auto_start_desc: 'Khởi động proxy khi ứng dụng mở lên',
},
proxy_tab: 'Proxy',
save: 'Lưu cài đặt',
},
toast: {
backupSuccess: {
title: 'Thành công',
description: 'Đã tạo bản sao lưu tài khoản.',
},
backupError: {
title: 'Lỗi',
description: 'Không thể tạo bản sao lưu: {{error}}',
},
switchSuccess: {
title: 'Thành công',
description: 'Chuyển tài khoản thành công.',
},
switchError: {
title: 'Lỗi',
description: 'Không thể chuyển tài khoản: {{error}}',
},
deleteSuccess: {
title: 'Thành công',
description: 'Đã xóa bản sao lưu tài khoản.',
},
deleteError: {
title: 'Lỗi',
description: 'Không thể xóa bản sao lưu: {{error}}',
},
},
cloud: {
title: 'Tài khoản',
description: 'Quản lý pool tài khoản Google Gemini của bạn.',
autoSwitch: 'Tự động chuyển',
providerGroupings: 'Nhóm theo nhà cung cấp',
addAccount: 'Thêm tài khoản',
syncFromIDE: 'Đồng bộ từ IDE',
checkQuota: 'Kiểm tra quota ngay',
polling: 'Đã kích hoạt polling',
globalQuota: 'Quota tổng',
layout: {
auto: 'Tự động',
twoCol: '2 cột',
threeCol: '3 cột',
list: 'Danh sách',
compact: 'Thu gọn',
},
authDialog: {
title: 'Thêm tài khoản Google',
description: 'Bạn cần cấp quyền ứng dụng để thêm tài khoản.',
oauthClient: 'OAuth Client',
oauthClientPlaceholder: 'Chọn OAuth client',
openLogin: 'Mở trang đăng nhập',
authCode: 'Mã ủy quyền',
placeholder: 'Dán mã bắt đầu bằng 4/...',
instruction:
'Ứng dụng sẽ mở trình duyệt mặc định để đăng nhập Google. Sao chép mã từ trang localhost và dán vào đây.',
verify: 'Xác thực và thêm',
},
card: {
active: 'Đang hoạt động',
use: 'Sử dụng',
rateLimited: 'Bị giới hạn tốc độ',
validationRiskControlled: 'Rủi ro / Bị giới hạn',
validationOAuthReauthRequired: 'Cần xác thực lại OAuth',
validationRequired: 'Cần xác minh',
left: 'còn lại',
used: 'Đã dùng',
unknown: 'Người dùng không rõ',
actions: 'Hành động',
useAccount: 'Dùng tài khoản này',
identityProfile: 'Hồ sơ định danh',
refresh: 'Làm mới quota',
delete: 'Xóa tài khoản',
noQuota: 'Chưa có dữ liệu quota',
rateLimitedQuota: 'Bị giới hạn',
resetPrefix: 'reset',
resetTime: 'Thời điểm reset',
resetUnknown: 'Không rõ',
gemini3Ready: 'Sẵn sàng cho Gemini 3',
groupGoogleGemini: 'Google Gemini',
groupAnthropicClaude: 'Anthropic Claude',
proxy: 'Proxy',
proxyPlaceholder: 'ví dụ: http://127.0.0.1:7890',
proxySaved: 'Đã lưu proxy',
noProxy: 'Không dùng proxy',
aiCredits: 'AI Credits',
creditsExpiry: 'hết hạn {{date}}',
modelVisibility: 'Hiển thị model',
},
identity: {
title: 'Hồ sơ định danh',
loading: 'Đang tải...',
generateAndBind: 'Tạo và gán',
captureAndBind: 'Lấy và gán hồ sơ hiện tại',
restoreOriginal: 'Khôi phục baseline',
openFolder: 'Mở thư mục lưu hồ sơ',
previewTitle: 'Xem trước hồ sơ đã tạo',
confirm: 'Xác nhận',
cancel: 'Hủy',
close: 'Đóng',
currentStorage: 'Hồ sơ runtime hiện tại',
accountBinding: 'Hồ sơ đang gán với tài khoản',
history: 'Lịch sử hồ sơ',
noHistory: 'Chưa có lịch sử hồ sơ',
current: 'Đang dùng',
restore: 'Khôi phục',
generateSuccess: 'Đã tạo và gán hồ sơ',
captureSuccess: 'Đã lấy và gán hồ sơ hiện tại',
restoreOriginalSuccess: 'Đã khôi phục baseline',
restoreVersionSuccess: 'Đã khôi phục bản lịch sử',
deleteVersionSuccess: 'Đã xóa bản lịch sử',
openFolderSuccess: 'Đã mở thư mục lưu hồ sơ',
baseline: 'Hồ sơ baseline',
},
list: {
noAccounts: 'Chưa có tài khoản cloud nào được thêm.',
},
error: {
loadFailed: 'Không thể tải danh sách tài khoản cloud.',
},
toast: {
syncSuccess: {
title: 'Đồng bộ thành công',
description: 'Đã nhập {{email}} từ IDE.',
},
syncFailed: {
title: 'Đồng bộ thất bại',
description: 'Không tìm thấy tài khoản đang hoạt động trong database của IDE.',
},
addSuccess: 'Đã thêm tài khoản thành công!',
addFailed: {
title: 'Không thể thêm tài khoản',
},
quotaRefreshed: 'Đã làm mới quota',
refreshFailed: 'Không thể làm mới quota',
pollFailed: 'Không thể polling quota của tất cả tài khoản',
switched: {
title: 'Đã chuyển tài khoản!',
description: 'Đang khởi động lại Antigravity...',
},
switchFailed: 'Không thể chuyển tài khoản',
deleted: 'Đã xóa tài khoản',
deleteFailed: 'Không thể xóa tài khoản',
deleteConfirm: 'Bạn có chắc chắn muốn xóa tài khoản này không?',
autoSwitchOn: 'Đã bật tự động chuyển',
autoSwitchOff: 'Đã tắt tự động chuyển',
updateSettingsFailed: 'Không thể cập nhật cài đặt',
actionFailed: 'Không thể thực hiện thao tác',
startAuthFailed: 'Không thể bắt đầu luồng đăng nhập',
refreshCreditsAvailable: 'AI credits: ${{amount}}',
refreshCreditsUnavailable: 'Không có thông tin AI credits cho lần làm mới này.',
batchRefreshSuccess: 'Đã làm mới thành công {{count}} tài khoản.',
batchRefreshPartial: {
title: 'Làm mới hoàn tất nhưng có lỗi',
description: 'Đã làm mới {{successful}} tài khoản, {{failed}} tài khoản thất bại.',
},
batchDeleteSuccess: 'Đã xóa thành công {{count}} tài khoản.',
batchDeletePartial: {
title: 'Xóa hoàn tất nhưng có lỗi',
description: 'Đã xóa {{successful}} tài khoản, {{failed}} tài khoản thất bại.',
},
},
batch: {
selected: 'Đã chọn {{count}}',
delete: 'Xóa mục đã chọn',
refresh: 'Làm mới mục đã chọn',
selectAll: 'Chọn tất cả',
clear: 'Bỏ chọn',
confirmDelete: 'Bạn có chắc chắn muốn xóa {{count}} tài khoản không?',
},
sort: {
recentlyUsed: 'Dùng gần đây',
quotaOverall: 'Quota tổng',
quotaClaude: 'Quota Claude',
quotaPro3: 'Quota Pro3',
quotaFlash: 'Quota Flash',
},
exportImport: {
export: 'Xuất',
import: 'Nhập',
exportTitle: 'Xuất tài khoản',
exportDesc: 'Chọn có bao gồm token xác thực trong tệp xuất hay không.',
includeTokens: 'Bao gồm token (kém an toàn hơn)',
stripTokens: 'Loại bỏ token (an toàn hơn khi chia sẻ)',
exportSuccess: 'Đã xuất tài khoản thành công',
importTitle: 'Nhập tài khoản',
importDesc: 'Chọn tệp JSON đã được xuất trước đó.',
importStrategy: 'Chiến lược nhập',
strategyMerge: 'Gộp - Cập nhật tài khoản cũ, thêm tài khoản mới',
strategyOverwrite: 'Ghi đè - Thay thế toàn bộ dữ liệu hiện có',
strategySkip: 'Bỏ qua - Chỉ thêm tài khoản mới',
importSuccess: 'Đã nhập {{imported}}, cập nhật {{updated}}, bỏ qua {{skipped}}',
importErrors: 'Đã nhập xong với {{count}} lỗi',
selectFile: 'Chọn tệp',
importing: 'Đang nhập...',
fileTooLarge: 'Kích thước tệp vượt quá giới hạn 5MB',
invalidJson: 'Định dạng tệp JSON không hợp lệ',
readFileFailed: 'Không thể đọc tệp',
},
},
proxy: {
title: 'API Proxy',
description: 'Quản lý local API proxy service.',
save: 'Lưu cài đặt',
copy: 'Sao chép',
copied: 'Đã sao chép!',
regenerate: 'Tạo lại',
regenerateConfirm: {
title: 'Tạo lại API Key?',
description:
'API key hiện tại sẽ hết hiệu lực ngay lập tức. Các ứng dụng đang dùng key cũ sẽ ngừng hoạt động.',
cancel: 'Hủy',
confirm: 'Tạo lại',
},
service: {
title: 'Trạng thái dịch vụ',
description: 'Điều khiển local API proxy server.',
running: 'Đang chạy',
stopped: 'Đã dừng',
start: 'Bắt đầu dịch vụ',
stop: 'Dừng dịch vụ',
},
config: {
port: 'Cổng lắng nghe',
timeout: 'Thời gian chờ request',
api_key: 'API Key',
auto_start: 'Tự động chạy cùng app',
auto_start_desc: 'Khởi động proxy khi ứng dụng mở lên',
local_access: 'Truy cập trong mạng nội bộ:',
select_ip: 'Chọn IP',
no_token_warning: 'API key chưa được đặt. Bất kỳ ai trong mạng đều có thể truy cập!',
show_key: 'Hiện',
hide_key: 'Ẩn',
},
mapping: {
title: 'Ánh xạ mô hình',
description: 'Ánh xạ model Claude sang Gemini để định tuyến.',
maps_to: 'Ánh xạ tới',
restore: 'Khôi phục mặc định',
},
examples: {
title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.',
},
},
};
export default vi;
+35 -59
View File
@@ -257,16 +257,14 @@ print(response.choices[0].message.content)`;
return (
<div className="container mx-auto max-w-4xl space-y-5 p-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title', 'API Proxy')}</h2>
<p className="text-muted-foreground mt-1">
{t('proxy.description', 'Manage the local API proxy service.')}
</p>
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title')}</h2>
<p className="text-muted-foreground mt-1">{t('proxy.description')}</p>
{/* Local Access Info Banner */}
{proxyConfig?.enabled && (
<div className="mt-4 flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
<div className="flex items-center gap-2">
<div className="font-semibold">{t('proxy.config.local_access', 'Local Access:')}</div>
<div className="font-semibold">{t('proxy.config.local_access')}</div>
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
</code>
@@ -274,7 +272,7 @@ print(response.choices[0].message.content)`;
{localIps && localIps.length > 1 && (
<Select value={selectedIp} onValueChange={setSelectedIp}>
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
<SelectValue placeholder="Select IP" />
<SelectValue placeholder={t('proxy.config.select_ip')} />
</SelectTrigger>
<SelectContent>
{localIps.map((ip) => (
@@ -288,10 +286,7 @@ print(response.choices[0].message.content)`;
</div>
{!proxyConfig.api_key && (
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
{t(
'proxy.config.no_token_warning',
'⚠️ No API Key set. Service is open to the public network!',
)}
{t('proxy.config.no_token_warning')}
</div>
)}
</div>
@@ -303,19 +298,15 @@ print(response.choices[0].message.content)`;
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{t('proxy.service.title', 'Service Status')}</CardTitle>
<CardDescription>
{t('proxy.service.description', 'Control the local API proxy server.')}
</CardDescription>
<CardTitle>{t('proxy.service.title')}</CardTitle>
<CardDescription>{t('proxy.service.description')}</CardDescription>
</div>
<div className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`}
></div>
<span className="text-sm font-medium">
{proxyConfig.enabled
? t('proxy.service.running', 'Running')
: t('proxy.service.stopped', 'Stopped')}
{proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
</span>
</div>
</div>
@@ -336,16 +327,14 @@ print(response.choices[0].message.content)`;
}
}}
>
{proxyConfig.enabled
? t('proxy.service.stop', 'Stop Service')
: t('proxy.service.start', 'Start Service')}
{proxyConfig.enabled ? t('proxy.service.stop') : t('proxy.service.start')}
</Button>
</div>
{/* Port & Timeout Configuration */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="gateway-port">{t('proxy.config.port', 'Listen Port')}</Label>
<Label htmlFor="gateway-port">{t('proxy.config.port')}</Label>
<Input
id="gateway-port"
type="number"
@@ -357,9 +346,7 @@ print(response.choices[0].message.content)`;
/>
</div>
<div className="space-y-2">
<Label htmlFor="gateway-timeout">
{t('proxy.config.timeout', 'Request Timeout')}
</Label>
<Label htmlFor="gateway-timeout">{t('proxy.config.timeout')}</Label>
<Input
id="gateway-timeout"
type="number"
@@ -376,7 +363,7 @@ print(response.choices[0].message.content)`;
{/* API Key */}
<div className="space-y-2">
<Label>{t('proxy.config.api_key', 'API Key')}</Label>
<Label>{t('proxy.config.api_key')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -405,28 +392,21 @@ print(response.choices[0].message.content)`;
onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')}
>
<Copy size={14} className="mr-1" />
{t('proxy.copy', 'Copy')}
{t('proxy.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
{t('proxy.regenerate', 'Regenerate')}
{t('proxy.regenerate')}
</Button>
</div>
<Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('proxy.regenerateConfirm.title', 'Regenerate API Key?')}
</DialogTitle>
<DialogDescription>
{t(
'proxy.regenerateConfirm.description',
'This will invalidate the current API key immediately. Any applications using the old key will stop working.',
)}
</DialogDescription>
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
{t('proxy.regenerateConfirm.cancel', 'Cancel')}
{t('proxy.regenerateConfirm.cancel')}
</Button>
<Button
variant="destructive"
@@ -437,7 +417,7 @@ print(response.choices[0].message.content)`;
setIsRegenerateDialogOpen(false);
}}
>
{t('proxy.regenerateConfirm.confirm', 'Regenerate')}
{t('proxy.regenerateConfirm.confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -447,10 +427,8 @@ print(response.choices[0].message.content)`;
{/* Auto Start Toggle */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('proxy.config.auto_start', 'Auto Start with App')}</Label>
<p className="text-xs text-gray-500">
{t('proxy.config.auto_start_desc', 'Start proxy service when application launches')}
</p>
<Label>{t('proxy.config.auto_start')}</Label>
<p className="text-xs text-gray-500">{t('proxy.config.auto_start_desc')}</p>
</div>
<Switch
checked={proxyConfig.auto_start}
@@ -465,10 +443,8 @@ print(response.choices[0].message.content)`;
{/* Model Mapping Card */}
<Card>
<CardHeader>
<CardTitle>{t('proxy.mapping.title', 'Model Mapping')}</CardTitle>
<CardDescription>
{t('proxy.mapping.description', 'Map Claude models to Gemini models for routing.')}
</CardDescription>
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -481,7 +457,7 @@ print(response.choices[0].message.content)`;
</h3>
</div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')}
{t('proxy.mapping.maps_to')}
</p>
<Select
value={resolveAnthropicMappingValue(
@@ -518,7 +494,7 @@ print(response.choices[0].message.content)`;
</h3>
</div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')}
{t('proxy.mapping.maps_to')}
</p>
<Select
value={resolveAnthropicMappingValue(
@@ -558,7 +534,7 @@ print(response.choices[0].message.content)`;
})
}
>
{t('proxy.mapping.restore', 'Restore Defaults')}
{t('proxy.mapping.restore')}
</Button>
</div>
</CardContent>
@@ -569,11 +545,9 @@ print(response.choices[0].message.content)`;
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code size={20} />
{t('proxy.examples.title', 'Usage Examples')}
{t('proxy.examples.title')}
</CardTitle>
<CardDescription>
{t('proxy.examples.description', 'Example commands to call the local API proxy.')}
</CardDescription>
<CardDescription>{t('proxy.examples.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 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'}`}
></div>
<span className="text-sm font-bold text-blue-700 dark:text-blue-400">
OpenAI Protocol
{t('settings.examples.openai_protocol')}
</span>
</div>
<div className="mb-2 rounded border border-blue-200/50 bg-white/60 px-3 py-2 dark:border-blue-700/30 dark:bg-gray-800/40">
@@ -597,7 +571,7 @@ print(response.choices[0].message.content)`;
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
💡 Cursor, Windsurf, NextChat
{t('settings.examples.openai_tools')}
</p>
</div>
@@ -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'}`}
></div>
<span className="text-sm font-bold text-purple-700 dark:text-purple-400">
Anthropic Protocol
{t('settings.examples.anthropic_protocol')}
</span>
</div>
<div className="mb-2 rounded border border-purple-200/50 bg-white/60 px-3 py-2 dark:border-purple-700/30 dark:bg-gray-800/40">
@@ -619,7 +593,9 @@ print(response.choices[0].message.content)`;
POST /v1/messages
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">💡 Claude Code CLI</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('settings.examples.anthropic_tools')}
</p>
</div>
</div>
@@ -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' ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied === 'curl' ? 'Copied!' : 'Copy'}
{copied === 'curl' ? t('proxy.copied') : t('proxy.copy')}
</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
@@ -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' ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied === 'python' ? 'Copied!' : 'Copy'}
{copied === 'python' ? t('proxy.copied') : t('proxy.copy')}
</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
+33 -79
View File
@@ -84,9 +84,9 @@ function SettingsPage() {
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">{t('settings.general', 'General')}</TabsTrigger>
<TabsTrigger value="models">{t('settings.models', 'Models')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy_tab', 'Proxy')}</TabsTrigger>
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="models">{t('settings.models')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy_tab')}</TabsTrigger>
</TabsList>
{/* --- GENERAL TAB --- */}
@@ -130,6 +130,7 @@ function SettingsPage() {
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
<SelectItem value="vi">{t('settings.language.vietnamese')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -139,22 +140,15 @@ function SettingsPage() {
{/* Account Settings Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.account.title', 'Account Settings')}</CardTitle>
<CardDescription>
{t('settings.account.description', 'Configure automatic account refresh and sync.')}
</CardDescription>
<CardTitle>{t('settings.account.title')}</CardTitle>
<CardDescription>{t('settings.account.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Auto Refresh Quota */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.account.auto_refresh', 'Auto Refresh Quota')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.account.auto_refresh_desc',
'Periodically refresh quota info for all accounts',
)}
</p>
<Label>{t('settings.account.auto_refresh')}</Label>
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
</div>
<Switch
checked={config?.auto_refresh || false}
@@ -169,13 +163,8 @@ function SettingsPage() {
{/* Auto Sync Account */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.account.auto_sync', 'Auto Sync Current Account')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.account.auto_sync_desc',
'Periodically sync active account information',
)}
</p>
<Label>{t('settings.account.auto_sync')}</Label>
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
</div>
<Switch
checked={config?.auto_sync || false}
@@ -192,23 +181,15 @@ function SettingsPage() {
{isAutoStartSupported && (
<Card>
<CardHeader>
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle>
<CardDescription>
{t(
'settings.startup.description',
'Control application launch behavior at system startup.',
)}
</CardDescription>
<CardTitle>{t('settings.startup.title')}</CardTitle>
<CardDescription>{t('settings.startup.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.startup.auto_startup', 'Start with system')}</Label>
<Label>{t('settings.startup.auto_startup')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.startup.auto_startup_desc',
'Launch at sign-in and keep the app in the system tray',
)}
{t('settings.startup.auto_startup_desc')}
</p>
</div>
<Switch
@@ -222,10 +203,7 @@ function SettingsPage() {
</div>
{isMac && (
<p className="text-muted-foreground text-xs">
{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')}
</p>
)}
</CardContent>
@@ -240,10 +218,10 @@ function SettingsPage() {
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="text-muted-foreground">{t('settings.version')}</div>
<div className="font-medium">{appVersion || 'Unknown'}</div>
<div className="font-medium">{appVersion || t('common.unknown')}</div>
<div className="text-muted-foreground">{t('settings.platform')}</div>
<div className="font-medium capitalize">{platform || 'Unknown'}</div>
<div className="font-medium capitalize">{platform || t('common.unknown')}</div>
<div className="text-muted-foreground">{t('settings.license')}</div>
<div className="font-medium">CC BY-NC-SA 4.0</div>
@@ -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"
>
<FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir', 'Open')}</span>
<span>{t('settings.openLogDir')}</span>
</button>
</div>
</CardContent>
@@ -263,23 +241,15 @@ function SettingsPage() {
{/* Privacy & Error Reporting Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
<CardDescription>
{t(
'settings.privacy.description',
'Control how your data is used to improve the application.',
)}
</CardDescription>
<CardTitle>{t('settings.privacy.title')}</CardTitle>
<CardDescription>{t('settings.privacy.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
<Label>{t('settings.privacy.error_reporting')}</Label>
<p className="text-xs text-gray-500">
{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')}
</p>
</div>
<Switch
@@ -291,35 +261,22 @@ function SettingsPage() {
}}
/>
</div>
<p className="text-muted-foreground text-xs">
{t(
'settings.privacy.restart_note',
'Changes to error reporting will take effect after restarting the application.',
)}
</p>
<p className="text-muted-foreground text-xs">{t('settings.privacy.restart_note')}</p>
</CardContent>
</Card>
{/* Notifications Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.notifications.title', 'Notifications')}</CardTitle>
<CardDescription>
{t(
'settings.notifications.description',
'Configure desktop alerts for account events.',
)}
</CardDescription>
<CardTitle>{t('settings.notifications.title')}</CardTitle>
<CardDescription>{t('settings.notifications.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.notifications.quotaAlert', 'Low Quota Alerts')}</Label>
<Label>{t('settings.notifications.quotaAlert')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.notifications.quotaAlertDesc',
'Get notified when a model quota drops below the set threshold',
)}
{t('settings.notifications.quotaAlertDesc')}
</p>
</div>
<Switch
@@ -328,10 +285,10 @@ function SettingsPage() {
if (config) {
try {
await saveConfig({ ...config, quota_alert_enabled: checked });
} catch (err) {
} catch {
toast({
title: t('common.error'),
description: 'Failed to save notification settings',
description: t('settings.notifications.saveFailed'),
variant: 'destructive',
});
}
@@ -341,12 +298,9 @@ function SettingsPage() {
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.notifications.quotaThreshold', 'Alert Threshold')}</Label>
<Label>{t('settings.notifications.quotaThreshold')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.notifications.quotaThresholdDesc',
'Percentage below which to trigger an alert',
)}
{t('settings.notifications.quotaThresholdDesc')}
</p>
</div>
<div className="flex items-center gap-2">
@@ -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',
});
}
+57 -4
View File
@@ -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<string, unknown> };
}): 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<boolean>('quota_alert_enabled', false);
const alertThreshold = CloudAccountRepo.getSetting<number>('quota_alert_threshold', 20);
const notificationLanguage = getCloudMonitorLanguage(
CloudAccountRepo.getSetting<string>('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();
}
+12 -1
View File
@@ -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';
}