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
+814 -184

No files matched your search

+3 -2
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react'; import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
import { formatDistanceToNow, type Locale } from 'date-fns'; 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 { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -22,6 +22,7 @@ const DATE_LOCALE_MAP: Record<string, Locale> = {
en: enUS, en: enUS,
'zh-CN': zhCN, 'zh-CN': zhCN,
ru: ru, ru: ru,
vi: vi,
}; };
interface AccountCardProps { interface AccountCardProps {
@@ -162,7 +163,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span> <span className="sr-only">{t('common.openMenu')}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
+20 -12
View File
@@ -316,14 +316,16 @@ export function CloudAccountList() {
if (isNumber(credits)) { if (isNumber(credits)) {
toast({ toast({
title: t('cloud.toast.quotaRefreshed'), title: t('cloud.toast.quotaRefreshed'),
description: `AI credits: $${credits.toFixed(2)}`, description: t('cloud.toast.refreshCreditsAvailable', {
amount: credits.toFixed(2),
}),
}); });
return; return;
} }
toast({ toast({
title: t('cloud.toast.quotaRefreshed'), 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' }), onError: () => toast({ title: t('cloud.toast.refreshFailed'), variant: 'destructive' }),
@@ -483,7 +485,7 @@ export function CloudAccountList() {
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
toast({ toast({
title: t('cloud.error.loadFailed'), title: t('cloud.error.loadFailed'),
description: 'File size exceeds 5MB limit', description: t('cloud.exportImport.fileTooLarge'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@@ -496,10 +498,10 @@ export function CloudAccountList() {
const content = event.target?.result as string; const content = event.target?.result as string;
JSON.parse(content); JSON.parse(content);
setImportFileContent(content); setImportFileContent(content);
} catch (err) { } catch {
toast({ toast({
title: t('cloud.error.loadFailed'), title: t('cloud.error.loadFailed'),
description: 'Invalid JSON file format', description: t('cloud.exportImport.invalidJson'),
variant: 'destructive', variant: 'destructive',
}); });
setImportFileName(''); setImportFileName('');
@@ -509,7 +511,7 @@ export function CloudAccountList() {
reader.onerror = () => { reader.onerror = () => {
toast({ toast({
title: t('cloud.error.loadFailed'), title: t('cloud.error.loadFailed'),
description: 'Failed to read file', description: t('cloud.exportImport.readFileFailed'),
variant: 'destructive', variant: 'destructive',
}); });
}; };
@@ -588,12 +590,15 @@ export function CloudAccountList() {
if (failed === 0) { if (failed === 0) {
toast({ toast({
title: t('cloud.toast.quotaRefreshed'), title: t('cloud.toast.quotaRefreshed'),
description: `Successfully refreshed ${successful} accounts.`, description: t('cloud.toast.batchRefreshSuccess', { count: successful }),
}); });
} else { } else {
toast({ toast({
title: `Refresh completed with issues`, title: t('cloud.toast.batchRefreshPartial.title'),
description: `Refreshed ${successful} accounts, ${failed} failed.`, description: t('cloud.toast.batchRefreshPartial.description', {
successful,
failed,
}),
variant: 'destructive', variant: 'destructive',
}); });
} }
@@ -614,12 +619,15 @@ export function CloudAccountList() {
if (failed === 0) { if (failed === 0) {
toast({ toast({
title: t('cloud.toast.deleted'), title: t('cloud.toast.deleted'),
description: `Successfully deleted ${successful} accounts.`, description: t('cloud.toast.batchDeleteSuccess', { count: successful }),
}); });
} else { } else {
toast({ toast({
title: `Deletion completed with issues`, title: t('cloud.toast.batchDeletePartial.title'),
description: `Deleted ${successful} accounts, ${failed} failed.`, description: t('cloud.toast.batchDeletePartial.description', {
successful,
failed,
}),
variant: 'destructive', variant: 'destructive',
}); });
} }
+21 -17
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 { useTranslation } from 'react-i18next';
import { import {
Fingerprint, Fingerprint,
@@ -48,11 +48,14 @@ function formatError(error: unknown): string {
return String(error); return String(error);
} }
function renderProfile(profile?: DeviceProfile): JSX.Element { function renderProfile(
profile: DeviceProfile | undefined,
t: ReturnType<typeof useTranslation>['t'],
): JSX.Element {
if (!profile) { if (!profile) {
return ( return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs"> <div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
N/A {t('common.notAvailable')}
</div> </div>
); );
} }
@@ -95,7 +98,8 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]); const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
const showLoadingPlaceholder = initialLoading && !snapshot; const showLoadingPlaceholder = initialLoading && !snapshot;
const refreshProfiles = async (options?: { silent?: boolean }) => { const refreshProfiles = useCallback(
async (options?: { silent?: boolean }) => {
if (!account) { if (!account) {
setSnapshot(null); setSnapshot(null);
return; return;
@@ -111,7 +115,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
setSnapshot(result); setSnapshot(result);
} catch (error) { } catch (error) {
toast({ toast({
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }), title: t('cloud.toast.actionFailed'),
description: formatError(error), description: formatError(error),
variant: 'destructive', variant: 'destructive',
}); });
@@ -122,14 +126,16 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
setInitialLoading(false); setInitialLoading(false);
} }
} }
}; },
[account, t, toast],
);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
} }
refreshProfiles(); void refreshProfiles();
}, [open, account?.id]); }, [open, refreshProfiles]);
const runAction = async (key: string, action: () => Promise<void>) => { const runAction = async (key: string, action: () => Promise<void>) => {
if (actionLockRef.current) { if (actionLockRef.current) {
@@ -142,7 +148,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
await refreshProfiles({ silent: true }); await refreshProfiles({ silent: true });
} catch (error) { } catch (error) {
toast({ toast({
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }), title: t('cloud.toast.actionFailed'),
description: formatError(error), description: formatError(error),
variant: 'destructive', variant: 'destructive',
}); });
@@ -247,7 +253,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle> <CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{renderProfile(previewProfile)} {renderProfile(previewProfile, t)}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
size="sm" size="sm"
@@ -295,7 +301,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{t('cloud.identity.loading')} {t('cloud.identity.loading')}
</div> </div>
) : ( ) : (
renderProfile(snapshot?.currentStorage) renderProfile(snapshot?.currentStorage, t)
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -313,7 +319,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{t('cloud.identity.loading')} {t('cloud.identity.loading')}
</div> </div>
) : ( ) : (
renderProfile(snapshot?.boundProfile) renderProfile(snapshot?.boundProfile, t)
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -353,7 +359,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
) : null} ) : null}
</div> </div>
{renderProfile(version.profile)} {renderProfile(version.profile, t)}
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button <Button
@@ -402,15 +408,13 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
<Card className="shadow-sm"> <Card className="shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm"> <CardTitle className="text-sm">{t('cloud.identity.baseline')}</CardTitle>
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{showLoadingPlaceholder ? ( {showLoadingPlaceholder ? (
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div> <div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
) : ( ) : (
renderProfile(snapshot?.baseline) renderProfile(snapshot?.baseline, t)
)} )}
</CardContent> </CardContent>
</Card> </Card>
+3 -1
View File
@@ -130,7 +130,9 @@ export function ModelVisibilitySettings() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<span>{t('settings.modelVisibility.title')}</span> <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> </CardTitle>
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription> <CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
</CardHeader> </CardHeader>
+6 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/ui/use-toast'; import { toast } from '@/components/ui/use-toast';
import { ipc } from '@/ipc/manager'; import { ipc } from '@/ipc/manager';
@@ -9,6 +10,7 @@ import { AppConfig } from '@/types/config';
const SAVE_DEBOUNCE_MS = 400; const SAVE_DEBOUNCE_MS = 400;
export function useAppConfig() { export function useAppConfig() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
@@ -57,8 +59,8 @@ export function useAppConfig() {
queryClient.setQueryData(['appConfig'], savedConfig); queryClient.setQueryData(['appConfig'], savedConfig);
lastStableConfigRef.current = savedConfig; lastStableConfigRef.current = savedConfig;
toast({ toast({
title: 'Settings saved', title: t('settings.toast.saved.title'),
description: 'Your configuration has been updated.', description: t('settings.toast.saved.description'),
}); });
for (const item of pendingBatch) { for (const item of pendingBatch) {
item.resolve(); item.resolve();
@@ -72,7 +74,7 @@ export function useAppConfig() {
queryClient.invalidateQueries({ queryKey: ['appConfig'] }); queryClient.invalidateQueries({ queryKey: ['appConfig'] });
} }
toast({ toast({
title: 'Error saving settings', title: t('settings.toast.saveFailed.title'),
description: error.message, description: error.message,
variant: 'destructive', variant: 'destructive',
}); });
@@ -80,7 +82,7 @@ export function useAppConfig() {
item.reject(error); item.reject(error);
} }
} }
}, [queryClient, updateConfig]); }, [queryClient, t, updateConfig]);
const saveConfig = useCallback( const saveConfig = useCallback(
(newConfig: AppConfig) => { (newConfig: AppConfig) => {
+33 -3
View File
@@ -25,18 +25,48 @@ const en: TrayTexts = {
const zh: TrayTexts = { const zh: TrayTexts = {
current: '当前账号', current: '当前账号',
quota: '当前额度', quota: '当前额度',
switch_next: '切换下一个账号', switch_next: '切换到下一个账号',
refresh_current: '刷新当前额度', refresh_current: '刷新当前额度',
show_window: '显示主窗口', show_window: '显示主窗口',
quit: '退出程序', quit: '退出应用',
no_account: '无账号', no_account: '无账号',
unknown_quota: '未知', 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 { export function getTrayTexts(lang: string = 'en'): TrayTexts {
if (lang.startsWith('zh')) { if (lang.startsWith('zh')) {
return zh; return zh;
} }
if (lang.startsWith('ru')) {
return ru;
}
if (lang.startsWith('vi')) {
return vi;
}
return en; return en;
} }
+1 -1
View File
@@ -44,7 +44,7 @@ export const MainLayout: React.FC = () => {
{ {
to: '/proxy', to: '/proxy',
icon: Network, icon: Network,
label: t('nav.proxy', 'API Proxy'), label: t('nav.proxy'),
}, },
{ {
to: '/settings', to: '/settings',
+119 -1
View File
@@ -1,6 +1,7 @@
import i18n from 'i18next'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import vi from './vi';
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
@@ -12,12 +13,19 @@ i18n
caches: ['localStorage'], caches: ['localStorage'],
lookupLocalStorage: 'lang', lookupLocalStorage: 'lang',
}, },
supportedLngs: ['en', 'zh-CN', 'ru'], supportedLngs: ['en', 'zh-CN', 'ru', 'vi'],
load: 'currentOnly', // Only load the exact language code, not variants load: 'currentOnly', // Only load the exact language code, not variants
resources: { resources: {
en: { en: {
translation: { translation: {
appName: 'Antigravity Manager', appName: 'Antigravity Manager',
common: {
loading: 'Loading...',
error: 'Error',
unknown: 'Unknown',
notAvailable: 'N/A',
openMenu: 'Open menu',
},
status: { status: {
checking: 'Checking status...', checking: 'Checking status...',
running: 'Antigravity is running in background', running: 'Antigravity is running in background',
@@ -84,6 +92,7 @@ i18n
english: 'English', english: 'English',
chinese: 'Chinese (Simplified)', chinese: 'Chinese (Simplified)',
russian: 'Russian', russian: 'Russian',
vietnamese: 'Vietnamese',
}, },
about: { about: {
title: 'About', title: 'About',
@@ -93,6 +102,15 @@ i18n
platform: 'Platform', platform: 'Platform',
license: 'License', license: 'License',
openLogDir: 'Open', openLogDir: 'Open',
toast: {
saved: {
title: 'Settings saved',
description: 'Your configuration has been updated.',
},
saveFailed: {
title: 'Error saving settings',
},
},
account: { account: {
title: 'Account Settings', title: 'Account Settings',
description: 'Configure automatic account refresh and sync.', description: 'Configure automatic account refresh and sync.',
@@ -125,6 +143,8 @@ i18n
quotaAlertDesc: 'Get notified when a model quota drops below the set threshold', quotaAlertDesc: 'Get notified when a model quota drops below the set threshold',
quotaThreshold: 'Alert Threshold', quotaThreshold: 'Alert Threshold',
quotaThresholdDesc: 'Percentage below which to trigger an alert', quotaThresholdDesc: 'Percentage below which to trigger an alert',
saveFailed: 'Failed to save notification settings',
thresholdSaveFailed: 'Failed to save threshold setting',
}, },
proxy: { proxy: {
title: 'Upstream Proxy', title: 'Upstream Proxy',
@@ -369,6 +389,20 @@ i18n
autoSwitchOn: 'Auto-Switch Enabled', autoSwitchOn: 'Auto-Switch Enabled',
autoSwitchOff: 'Auto-Switch Disabled', autoSwitchOff: 'Auto-Switch Disabled',
updateSettingsFailed: 'Failed to update settings', 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: { batch: {
selected: 'Selected {{count}}', selected: 'Selected {{count}}',
@@ -403,6 +437,9 @@ i18n
importErrors: 'Import completed with {{count}} error(s)', importErrors: 'Import completed with {{count}} error(s)',
selectFile: 'Select File', selectFile: 'Select File',
importing: 'Importing...', importing: 'Importing...',
fileTooLarge: 'File size exceeds 5MB limit',
invalidJson: 'Invalid JSON file format',
readFileFailed: 'Failed to read file',
}, },
}, },
proxy: { proxy: {
@@ -410,6 +447,7 @@ i18n
description: 'Manage the local API proxy service.', description: 'Manage the local API proxy service.',
save: 'Save Settings', save: 'Save Settings',
copy: 'Copy', copy: 'Copy',
copied: 'Copied!',
regenerate: 'Regenerate', regenerate: 'Regenerate',
regenerateConfirm: { regenerateConfirm: {
title: 'Regenerate API Key?', title: 'Regenerate API Key?',
@@ -433,6 +471,7 @@ i18n
auto_start: 'Auto Start with App', auto_start: 'Auto Start with App',
auto_start_desc: 'Start proxy service when application launches', auto_start_desc: 'Start proxy service when application launches',
local_access: 'Local network access:', local_access: 'Local network access:',
select_ip: 'Select IP',
no_token_warning: '⚠️ API key is not set. Access is open to everyone!', no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
show_key: 'Show', show_key: 'Show',
hide_key: 'Hide', hide_key: 'Hide',
@@ -450,9 +489,19 @@ i18n
}, },
}, },
}, },
vi: {
translation: vi,
},
'zh-CN': { 'zh-CN': {
translation: { translation: {
appName: 'Antigravity 管理器', appName: 'Antigravity 管理器',
common: {
loading: '加载中...',
error: '错误',
unknown: '未知',
notAvailable: '不适用',
openMenu: '打开菜单',
},
status: { status: {
checking: '正在检查状态...', checking: '正在检查状态...',
running: 'Antigravity 正在后台运行', running: 'Antigravity 正在后台运行',
@@ -519,6 +568,7 @@ i18n
english: '英语', english: '英语',
chinese: '中文 (简体)', chinese: '中文 (简体)',
russian: '俄语', russian: '俄语',
vietnamese: '越南语',
}, },
about: { about: {
title: '关于', title: '关于',
@@ -528,6 +578,15 @@ i18n
platform: '平台', platform: '平台',
license: '许可证', license: '许可证',
openLogDir: '打开', openLogDir: '打开',
toast: {
saved: {
title: '设置已保存',
description: '配置已更新。',
},
saveFailed: {
title: '保存设置失败',
},
},
account: { account: {
title: '账号设置', title: '账号设置',
description: '配置自动刷新和同步账号。', description: '配置自动刷新和同步账号。',
@@ -558,6 +617,8 @@ i18n
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒', quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
quotaThreshold: '提醒阈值', quotaThreshold: '提醒阈值',
quotaThresholdDesc: '触发提醒的百分比阈值', quotaThresholdDesc: '触发提醒的百分比阈值',
saveFailed: '保存通知设置失败',
thresholdSaveFailed: '保存阈值设置失败',
}, },
proxy: { proxy: {
title: '上游代理', title: '上游代理',
@@ -799,6 +860,20 @@ i18n
autoSwitchOn: '自动切换已启用', autoSwitchOn: '自动切换已启用',
autoSwitchOff: '自动切换已禁用', autoSwitchOff: '自动切换已禁用',
updateSettingsFailed: '更新设置失败', updateSettingsFailed: '更新设置失败',
actionFailed: '操作失败',
startAuthFailed: '启动登录流程失败',
refreshCreditsAvailable: 'AI 积分:${{amount}}',
refreshCreditsUnavailable: '本次刷新没有可用的 AI 积分信息。',
batchRefreshSuccess: '成功刷新 {{count}} 个账号。',
batchRefreshPartial: {
title: '刷新已完成,但存在问题',
description: '已刷新 {{successful}} 个账号,{{failed}} 个失败。',
},
batchDeleteSuccess: '成功删除 {{count}} 个账号。',
batchDeletePartial: {
title: '删除已完成,但存在问题',
description: '已删除 {{successful}} 个账号,{{failed}} 个失败。',
},
}, },
batch: { batch: {
selected: '已选 {{count}} 项', selected: '已选 {{count}} 项',
@@ -833,6 +908,9 @@ i18n
importErrors: '导入完成,但有 {{count}} 个错误', importErrors: '导入完成,但有 {{count}} 个错误',
selectFile: '选择文件', selectFile: '选择文件',
importing: '导入中...', importing: '导入中...',
fileTooLarge: '文件大小超过 5MB 限制',
invalidJson: 'JSON 文件格式无效',
readFileFailed: '读取文件失败',
}, },
}, },
proxy: { proxy: {
@@ -840,6 +918,7 @@ i18n
description: '管理本地 API 代理服务。', description: '管理本地 API 代理服务。',
save: '保存设置', save: '保存设置',
copy: '复制', copy: '复制',
copied: '已复制!',
regenerate: '重新生成', regenerate: '重新生成',
regenerateConfirm: { regenerateConfirm: {
title: '重新生成 API 密钥?', title: '重新生成 API 密钥?',
@@ -862,6 +941,7 @@ i18n
auto_start: '随应用启动', auto_start: '随应用启动',
auto_start_desc: '应用启动时自动启动代理服务', auto_start_desc: '应用启动时自动启动代理服务',
local_access: '局域网访问:', local_access: '局域网访问:',
select_ip: '选择 IP',
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!', no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
show_key: '显示密钥', show_key: '显示密钥',
hide_key: '隐藏密钥', hide_key: '隐藏密钥',
@@ -882,6 +962,13 @@ i18n
ru: { ru: {
translation: { translation: {
appName: 'Antigravity Manager', appName: 'Antigravity Manager',
common: {
loading: 'Загрузка...',
error: 'Ошибка',
unknown: 'Неизвестно',
notAvailable: 'Н/Д',
openMenu: 'Открыть меню',
},
status: { status: {
checking: 'Проверка статуса...', checking: 'Проверка статуса...',
running: 'Antigravity работает в фоне', running: 'Antigravity работает в фоне',
@@ -949,6 +1036,7 @@ i18n
english: 'English', english: 'English',
chinese: '中文 (简体)', chinese: '中文 (简体)',
russian: 'Русский', russian: 'Русский',
vietnamese: 'Вьетнамский',
}, },
about: { about: {
title: 'О программе', title: 'О программе',
@@ -958,6 +1046,15 @@ i18n
platform: 'Платформа', platform: 'Платформа',
license: 'Лицензия', license: 'Лицензия',
openLogDir: 'Открыть', openLogDir: 'Открыть',
toast: {
saved: {
title: 'Настройки сохранены',
description: 'Конфигурация обновлена.',
},
saveFailed: {
title: 'Ошибка сохранения настроек',
},
},
account: { account: {
title: 'Настройки аккаунта', title: 'Настройки аккаунта',
description: 'Настройка автоматического обновления и синхронизации.', description: 'Настройка автоматического обновления и синхронизации.',
@@ -991,6 +1088,8 @@ i18n
'Получайте уведомления, когда квота модели падает ниже установленного порога', 'Получайте уведомления, когда квота модели падает ниже установленного порога',
quotaThreshold: 'Порог уведомления', quotaThreshold: 'Порог уведомления',
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление', quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
saveFailed: 'Не удалось сохранить настройки уведомлений',
thresholdSaveFailed: 'Не удалось сохранить порог уведомления',
}, },
proxy: { proxy: {
title: 'Верхнеуровневый прокси', title: 'Верхнеуровневый прокси',
@@ -1234,6 +1333,20 @@ i18n
autoSwitchOn: 'Авто-переключение включено', autoSwitchOn: 'Авто-переключение включено',
autoSwitchOff: 'Авто-переключение выключено', autoSwitchOff: 'Авто-переключение выключено',
updateSettingsFailed: 'Не удалось обновить настройки', updateSettingsFailed: 'Не удалось обновить настройки',
actionFailed: 'Не удалось выполнить действие',
startAuthFailed: 'Не удалось запустить процесс входа',
refreshCreditsAvailable: 'AI кредиты: ${{amount}}',
refreshCreditsUnavailable: 'AI кредиты недоступны для этого обновления.',
batchRefreshSuccess: 'Успешно обновлено {{count}} аккаунтов.',
batchRefreshPartial: {
title: 'Обновление завершено с проблемами',
description: 'Обновлено {{successful}} аккаунтов, {{failed}} с ошибкой.',
},
batchDeleteSuccess: 'Успешно удалено {{count}} аккаунтов.',
batchDeletePartial: {
title: 'Удаление завершено с проблемами',
description: 'Удалено {{successful}} аккаунтов, {{failed}} с ошибкой.',
},
}, },
batch: { batch: {
selected: 'Выбрано {{count}}', selected: 'Выбрано {{count}}',
@@ -1269,6 +1382,9 @@ i18n
importErrors: 'Импорт завершён с {{count}} ошибкой(ами)', importErrors: 'Импорт завершён с {{count}} ошибкой(ами)',
selectFile: 'Выбрать файл', selectFile: 'Выбрать файл',
importing: 'Импорт...', importing: 'Импорт...',
fileTooLarge: 'Размер файла превышает лимит в 5 МБ',
invalidJson: 'Некорректный формат JSON-файла',
readFileFailed: 'Не удалось прочитать файл',
}, },
}, },
proxy: { proxy: {
@@ -1276,6 +1392,7 @@ i18n
description: 'Управление локальным прокси-сервером API.', description: 'Управление локальным прокси-сервером API.',
save: 'Сохранить', save: 'Сохранить',
copy: 'Копировать', copy: 'Копировать',
copied: 'Скопировано!',
regenerate: 'Перегенерировать', regenerate: 'Перегенерировать',
regenerateConfirm: { regenerateConfirm: {
title: 'Перегенерировать API ключ?', title: 'Перегенерировать API ключ?',
@@ -1299,6 +1416,7 @@ i18n
auto_start: 'Автозапуск', auto_start: 'Автозапуск',
auto_start_desc: 'Запускать прокси при старте приложения', auto_start_desc: 'Запускать прокси при старте приложения',
local_access: 'Доступ из локальной сети:', local_access: 'Доступ из локальной сети:',
select_ip: 'Выбрать IP',
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!', no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
show_key: 'Показать', show_key: 'Показать',
hide_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 ( return (
<div className="container mx-auto max-w-4xl space-y-5 p-6"> <div className="container mx-auto max-w-4xl space-y-5 p-6">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title', 'API Proxy')}</h2> <h2 className="text-3xl font-bold tracking-tight">{t('proxy.title')}</h2>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">{t('proxy.description')}</p>
{t('proxy.description', 'Manage the local API proxy service.')}
</p>
{/* Local Access Info Banner */} {/* Local Access Info Banner */}
{proxyConfig?.enabled && ( {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="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="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"> <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 http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
</code> </code>
@@ -274,7 +272,7 @@ print(response.choices[0].message.content)`;
{localIps && localIps.length > 1 && ( {localIps && localIps.length > 1 && (
<Select value={selectedIp} onValueChange={setSelectedIp}> <Select value={selectedIp} onValueChange={setSelectedIp}>
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs"> <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> </SelectTrigger>
<SelectContent> <SelectContent>
{localIps.map((ip) => ( {localIps.map((ip) => (
@@ -288,10 +286,7 @@ print(response.choices[0].message.content)`;
</div> </div>
{!proxyConfig.api_key && ( {!proxyConfig.api_key && (
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400"> <div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
{t( {t('proxy.config.no_token_warning')}
'proxy.config.no_token_warning',
'⚠️ No API Key set. Service is open to the public network!',
)}
</div> </div>
)} )}
</div> </div>
@@ -303,19 +298,15 @@ print(response.choices[0].message.content)`;
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>{t('proxy.service.title', 'Service Status')}</CardTitle> <CardTitle>{t('proxy.service.title')}</CardTitle>
<CardDescription> <CardDescription>{t('proxy.service.description')}</CardDescription>
{t('proxy.service.description', 'Control the local API proxy server.')}
</CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`} className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`}
></div> ></div>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{proxyConfig.enabled {proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
? t('proxy.service.running', 'Running')
: t('proxy.service.stopped', 'Stopped')}
</span> </span>
</div> </div>
</div> </div>
@@ -336,16 +327,14 @@ print(response.choices[0].message.content)`;
} }
}} }}
> >
{proxyConfig.enabled {proxyConfig.enabled ? t('proxy.service.stop') : t('proxy.service.start')}
? t('proxy.service.stop', 'Stop Service')
: t('proxy.service.start', 'Start Service')}
</Button> </Button>
</div> </div>
{/* Port & Timeout Configuration */} {/* Port & Timeout Configuration */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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 <Input
id="gateway-port" id="gateway-port"
type="number" type="number"
@@ -357,9 +346,7 @@ print(response.choices[0].message.content)`;
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="gateway-timeout"> <Label htmlFor="gateway-timeout">{t('proxy.config.timeout')}</Label>
{t('proxy.config.timeout', 'Request Timeout')}
</Label>
<Input <Input
id="gateway-timeout" id="gateway-timeout"
type="number" type="number"
@@ -376,7 +363,7 @@ print(response.choices[0].message.content)`;
{/* API Key */} {/* API Key */}
<div className="space-y-2"> <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="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
@@ -405,28 +392,21 @@ print(response.choices[0].message.content)`;
onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')} onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')}
> >
<Copy size={14} className="mr-1" /> <Copy size={14} className="mr-1" />
{t('proxy.copy', 'Copy')} {t('proxy.copy')}
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}> <Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
{t('proxy.regenerate', 'Regenerate')} {t('proxy.regenerate')}
</Button> </Button>
</div> </div>
<Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}> <Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
{t('proxy.regenerateConfirm.title', 'Regenerate API Key?')} <DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
</DialogTitle>
<DialogDescription>
{t(
'proxy.regenerateConfirm.description',
'This will invalidate the current API key immediately. Any applications using the old key will stop working.',
)}
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}> <Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
{t('proxy.regenerateConfirm.cancel', 'Cancel')} {t('proxy.regenerateConfirm.cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@@ -437,7 +417,7 @@ print(response.choices[0].message.content)`;
setIsRegenerateDialogOpen(false); setIsRegenerateDialogOpen(false);
}} }}
> >
{t('proxy.regenerateConfirm.confirm', 'Regenerate')} {t('proxy.regenerateConfirm.confirm')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -447,10 +427,8 @@ print(response.choices[0].message.content)`;
{/* Auto Start Toggle */} {/* Auto Start Toggle */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('proxy.config.auto_start', 'Auto Start with App')}</Label> <Label>{t('proxy.config.auto_start')}</Label>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">{t('proxy.config.auto_start_desc')}</p>
{t('proxy.config.auto_start_desc', 'Start proxy service when application launches')}
</p>
</div> </div>
<Switch <Switch
checked={proxyConfig.auto_start} checked={proxyConfig.auto_start}
@@ -465,10 +443,8 @@ print(response.choices[0].message.content)`;
{/* Model Mapping Card */} {/* Model Mapping Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('proxy.mapping.title', 'Model Mapping')}</CardTitle> <CardTitle>{t('proxy.mapping.title')}</CardTitle>
<CardDescription> <CardDescription>{t('proxy.mapping.description')}</CardDescription>
{t('proxy.mapping.description', 'Map Claude models to Gemini models for routing.')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -481,7 +457,7 @@ print(response.choices[0].message.content)`;
</h3> </h3>
</div> </div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400"> <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> </p>
<Select <Select
value={resolveAnthropicMappingValue( value={resolveAnthropicMappingValue(
@@ -518,7 +494,7 @@ print(response.choices[0].message.content)`;
</h3> </h3>
</div> </div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400"> <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> </p>
<Select <Select
value={resolveAnthropicMappingValue( value={resolveAnthropicMappingValue(
@@ -558,7 +534,7 @@ print(response.choices[0].message.content)`;
}) })
} }
> >
{t('proxy.mapping.restore', 'Restore Defaults')} {t('proxy.mapping.restore')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -569,11 +545,9 @@ print(response.choices[0].message.content)`;
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Code size={20} /> <Code size={20} />
{t('proxy.examples.title', 'Usage Examples')} {t('proxy.examples.title')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('proxy.examples.description')}</CardDescription>
{t('proxy.examples.description', 'Example commands to call the local API proxy.')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Protocol Selector Cards */} {/* 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'}`} className={`h-2 w-2 rounded-full ${selectedProtocol === 'openai' ? 'animate-pulse bg-blue-500' : 'bg-blue-400'}`}
></div> ></div>
<span className="text-sm font-bold text-blue-700 dark:text-blue-400"> <span className="text-sm font-bold text-blue-700 dark:text-blue-400">
OpenAI Protocol {t('settings.examples.openai_protocol')}
</span> </span>
</div> </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"> <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> </code>
</div> </div>
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
💡 Cursor, Windsurf, NextChat {t('settings.examples.openai_tools')}
</p> </p>
</div> </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'}`} className={`h-2 w-2 rounded-full ${selectedProtocol === 'anthropic' ? 'animate-pulse bg-purple-500' : 'bg-purple-400'}`}
></div> ></div>
<span className="text-sm font-bold text-purple-700 dark:text-purple-400"> <span className="text-sm font-bold text-purple-700 dark:text-purple-400">
Anthropic Protocol {t('settings.examples.anthropic_protocol')}
</span> </span>
</div> </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"> <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 POST /v1/messages
</code> </code>
</div> </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>
</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" 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' ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied === 'curl' ? 'Copied!' : 'Copy'} {copied === 'curl' ? t('proxy.copied') : t('proxy.copy')}
</button> </button>
</div> </div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100"> <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" 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' ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied === 'python' ? 'Copied!' : 'Copy'} {copied === 'python' ? t('proxy.copied') : t('proxy.copy')}
</button> </button>
</div> </div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100"> <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"> <Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">{t('settings.general', 'General')}</TabsTrigger> <TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="models">{t('settings.models', 'Models')}</TabsTrigger> <TabsTrigger value="models">{t('settings.models')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy_tab', 'Proxy')}</TabsTrigger> <TabsTrigger value="proxy">{t('settings.proxy_tab')}</TabsTrigger>
</TabsList> </TabsList>
{/* --- GENERAL TAB --- */} {/* --- GENERAL TAB --- */}
@@ -130,6 +130,7 @@ function SettingsPage() {
<SelectItem value="en">{t('settings.language.english')}</SelectItem> <SelectItem value="en">{t('settings.language.english')}</SelectItem>
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem> <SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem> <SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
<SelectItem value="vi">{t('settings.language.vietnamese')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -139,22 +140,15 @@ function SettingsPage() {
{/* Account Settings Card */} {/* Account Settings Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('settings.account.title', 'Account Settings')}</CardTitle> <CardTitle>{t('settings.account.title')}</CardTitle>
<CardDescription> <CardDescription>{t('settings.account.description')}</CardDescription>
{t('settings.account.description', 'Configure automatic account refresh and sync.')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Auto Refresh Quota */} {/* Auto Refresh Quota */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.account.auto_refresh', 'Auto Refresh Quota')}</Label> <Label>{t('settings.account.auto_refresh')}</Label>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
{t(
'settings.account.auto_refresh_desc',
'Periodically refresh quota info for all accounts',
)}
</p>
</div> </div>
<Switch <Switch
checked={config?.auto_refresh || false} checked={config?.auto_refresh || false}
@@ -169,13 +163,8 @@ function SettingsPage() {
{/* Auto Sync Account */} {/* Auto Sync Account */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.account.auto_sync', 'Auto Sync Current Account')}</Label> <Label>{t('settings.account.auto_sync')}</Label>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
{t(
'settings.account.auto_sync_desc',
'Periodically sync active account information',
)}
</p>
</div> </div>
<Switch <Switch
checked={config?.auto_sync || false} checked={config?.auto_sync || false}
@@ -192,23 +181,15 @@ function SettingsPage() {
{isAutoStartSupported && ( {isAutoStartSupported && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle> <CardTitle>{t('settings.startup.title')}</CardTitle>
<CardDescription> <CardDescription>{t('settings.startup.description')}</CardDescription>
{t(
'settings.startup.description',
'Control application launch behavior at system startup.',
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <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"> <p className="text-xs text-gray-500">
{t( {t('settings.startup.auto_startup_desc')}
'settings.startup.auto_startup_desc',
'Launch at sign-in and keep the app in the system tray',
)}
</p> </p>
</div> </div>
<Switch <Switch
@@ -222,10 +203,7 @@ function SettingsPage() {
</div> </div>
{isMac && ( {isMac && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{t( {t('settings.startup.macos_hint')}
'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.',
)}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -240,10 +218,10 @@ function SettingsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div className="text-muted-foreground">{t('settings.version')}</div> <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="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="text-muted-foreground">{t('settings.license')}</div>
<div className="font-medium">CC BY-NC-SA 4.0</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" 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" /> <FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir', 'Open')}</span> <span>{t('settings.openLogDir')}</span>
</button> </button>
</div> </div>
</CardContent> </CardContent>
@@ -263,23 +241,15 @@ function SettingsPage() {
{/* Privacy & Error Reporting Card */} {/* Privacy & Error Reporting Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle> <CardTitle>{t('settings.privacy.title')}</CardTitle>
<CardDescription> <CardDescription>{t('settings.privacy.description')}</CardDescription>
{t(
'settings.privacy.description',
'Control how your data is used to improve the application.',
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <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"> <p className="text-xs text-gray-500">
{t( {t('settings.privacy.error_reporting_desc')}
'settings.privacy.error_reporting_desc',
'Send anonymous error reports to help us improve the app. No personal data is collected.',
)}
</p> </p>
</div> </div>
<Switch <Switch
@@ -291,35 +261,22 @@ function SettingsPage() {
}} }}
/> />
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">{t('settings.privacy.restart_note')}</p>
{t(
'settings.privacy.restart_note',
'Changes to error reporting will take effect after restarting the application.',
)}
</p>
</CardContent> </CardContent>
</Card> </Card>
{/* Notifications Card */} {/* Notifications Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('settings.notifications.title', 'Notifications')}</CardTitle> <CardTitle>{t('settings.notifications.title')}</CardTitle>
<CardDescription> <CardDescription>{t('settings.notifications.description')}</CardDescription>
{t(
'settings.notifications.description',
'Configure desktop alerts for account events.',
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <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"> <p className="text-xs text-gray-500">
{t( {t('settings.notifications.quotaAlertDesc')}
'settings.notifications.quotaAlertDesc',
'Get notified when a model quota drops below the set threshold',
)}
</p> </p>
</div> </div>
<Switch <Switch
@@ -328,10 +285,10 @@ function SettingsPage() {
if (config) { if (config) {
try { try {
await saveConfig({ ...config, quota_alert_enabled: checked }); await saveConfig({ ...config, quota_alert_enabled: checked });
} catch (err) { } catch {
toast({ toast({
title: t('common.error'), title: t('common.error'),
description: 'Failed to save notification settings', description: t('settings.notifications.saveFailed'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@@ -341,12 +298,9 @@ function SettingsPage() {
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <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"> <p className="text-xs text-gray-500">
{t( {t('settings.notifications.quotaThresholdDesc')}
'settings.notifications.quotaThresholdDesc',
'Percentage below which to trigger an alert',
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -363,10 +317,10 @@ function SettingsPage() {
if (config) { if (config) {
try { try {
await saveConfig({ ...config, quota_alert_threshold: parsed }); await saveConfig({ ...config, quota_alert_threshold: parsed });
} catch (err) { } catch {
toast({ toast({
title: t('common.error'), title: t('common.error'),
description: 'Failed to save threshold setting', description: t('settings.notifications.thresholdSaveFailed'),
variant: 'destructive', variant: 'destructive',
}); });
} }
+57 -4
View File
@@ -5,6 +5,47 @@ import { AutoSwitchService } from './AutoSwitchService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { classifyAccountStatusFromError } from '../utils/account-status'; 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: { function hasReusableCachedQuota(account: {
quota?: { models?: Record<string, unknown> }; quota?: { models?: Record<string, unknown> };
}): boolean { }): boolean {
@@ -131,7 +172,11 @@ export class CloudMonitorService {
logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError); logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError);
const classified = classifyAccountStatusFromError(refreshError); const classified = classifyAccountStatusFromError(refreshError);
if (classified) { if (classified) {
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason); await CloudAccountRepo.setAccountStatus(
account.id,
classified.status,
classified.reason,
);
} }
continue; continue;
} }
@@ -163,7 +208,11 @@ export class CloudMonitorService {
logger.error(`Monitor: Failed to update ${account.email}`, error); logger.error(`Monitor: Failed to update ${account.email}`, error);
const classified = classifyAccountStatusFromError(error); const classified = classifyAccountStatusFromError(error);
if (classified) { 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)) { if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) {
logger.warn( logger.warn(
`Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`, `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 // 4. Check for Quota Alerts
const alertEnabled = CloudAccountRepo.getSetting<boolean>('quota_alert_enabled', false); const alertEnabled = CloudAccountRepo.getSetting<boolean>('quota_alert_enabled', false);
const alertThreshold = CloudAccountRepo.getSetting<number>('quota_alert_threshold', 20); 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) { if (alertEnabled) {
for (const account of accounts) { for (const account of accounts) {
@@ -188,8 +241,8 @@ export class CloudMonitorService {
if (lowQuotaModels.length > 0) { if (lowQuotaModels.length > 0) {
new Notification({ new Notification({
title: 'Low Quota Alert', title: notificationText.lowQuotaTitle,
body: `${account.email}: ${lowQuotaModels.join(', ')} are low on quota`, body: notificationText.lowQuotaBody(account.email, lowQuotaModels.join(', ')),
silent: false, silent: false,
}).show(); }).show();
} }
+12 -1
View File
@@ -1,6 +1,6 @@
import path from 'path'; import path from 'path';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru'; export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi';
const installNoticeText: Record< const installNoticeText: Record<
InstallNoticeLanguage, InstallNoticeLanguage,
@@ -32,6 +32,13 @@ const installNoticeText: Record<
detailPrefix: 'Папка установки: ', detailPrefix: 'Папка установки: ',
buttons: ['Открыть папку', 'Понятно'], 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({ export function resolveInstallNoticeLanguage({
@@ -55,6 +62,10 @@ export function resolveInstallNoticeLanguage({
return 'ru'; return 'ru';
} }
if (normalized.startsWith('vi')) {
return 'vi';
}
return 'en'; return 'en';
} }