mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
feat(i18n): add comprehensive Vietnamese dashboard locale
This commit is contained in:
@@ -67,7 +67,7 @@ The dashboard provides visual management for all account types:
|
||||
- **Factory Droid**: Track Droid install location and BYOK settings health
|
||||
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
||||
- **Health Monitor**: Real-time status across all profiles
|
||||
- **Language Switcher**: Toggle dashboard locale between English and Simplified Chinese
|
||||
- **Language Switcher**: Toggle dashboard locale between English, Simplified Chinese, and Vietnamese
|
||||
|
||||
**Analytics Dashboard**
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Dashboard i18n currently covers UI text rendered by React components.
|
||||
- Supported locales today:
|
||||
- `en` (English)
|
||||
- `zh-CN` (Simplified Chinese)
|
||||
- `vi` (Vietnamese)
|
||||
- Locale state is persisted in browser localStorage using `ccs-ui-locale`.
|
||||
- Fallback language is `en`.
|
||||
|
||||
@@ -36,6 +37,7 @@ Out of scope:
|
||||
- Contains `resources` object with locale blocks:
|
||||
- `en.translation`
|
||||
- `zh-CN.translation`
|
||||
- `vi.translation`
|
||||
- Uses `initReactI18next` for React integration.
|
||||
|
||||
### Locale utilities
|
||||
@@ -85,7 +87,7 @@ Examples exist in sync/account counters.
|
||||
|
||||
## Adding a New Locale
|
||||
|
||||
When adding a locale such as Vietnamese (`vi`):
|
||||
When adding a locale such as Thai (`th`):
|
||||
|
||||
1. Add locale id to the supported locale list in `ui/src/lib/locales.ts`.
|
||||
2. Add locale display label under `translation.locale` in `ui/src/lib/i18n.ts`.
|
||||
@@ -96,7 +98,7 @@ When adding a locale such as Vietnamese (`vi`):
|
||||
- `cd ui && bun run test:run tests/unit/ui/i18n/language-switcher.test.tsx`
|
||||
6. Add or update key-parity tests to catch locale drift.
|
||||
|
||||
Tracking issue for comprehensive Vietnamese locale:
|
||||
Current issue driving the Vietnamese rollout:
|
||||
|
||||
- https://github.com/kaitranntt/ccs/issues/659
|
||||
|
||||
|
||||
@@ -59,4 +59,5 @@ For full architecture, conventions, and locale onboarding, see:
|
||||
|
||||
- UI locale persistence uses browser localStorage key `ccs-ui-locale`.
|
||||
- Current supported locales are managed in `ui/src/lib/locales.ts`.
|
||||
- Current locales: `en`, `zh-CN`, `vi`.
|
||||
- Fallback locale is English (`en`).
|
||||
|
||||
@@ -399,6 +399,7 @@ function getStatusText(code: number): string {
|
||||
export function formatRelativeTime(modifiedSeconds: number, locale?: string): string {
|
||||
const formatLocale = getFormattingLocale(locale);
|
||||
const isZh = formatLocale === 'zh-CN';
|
||||
const isVi = formatLocale === 'vi';
|
||||
const now = Date.now();
|
||||
const modified = modifiedSeconds * 1000; // Convert to milliseconds
|
||||
const diff = now - modified;
|
||||
@@ -408,10 +409,26 @@ export function formatRelativeTime(modifiedSeconds: number, locale?: string): st
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return isZh ? '刚刚' : 'just now';
|
||||
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
|
||||
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
|
||||
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
|
||||
if (seconds < 60) {
|
||||
if (isZh) return '刚刚';
|
||||
if (isVi) return 'vừa xong';
|
||||
return 'just now';
|
||||
}
|
||||
if (minutes < 60) {
|
||||
if (isZh) return `${minutes} 分钟前`;
|
||||
if (isVi) return `${minutes} phút trước`;
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
if (hours < 24) {
|
||||
if (isZh) return `${hours} 小时前`;
|
||||
if (isVi) return `${hours} giờ trước`;
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
if (days < 7) {
|
||||
if (isZh) return `${days} 天前`;
|
||||
if (isVi) return `${days} ngày trước`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
// Format as date for older logs
|
||||
const date = new Date(modified);
|
||||
@@ -441,25 +458,39 @@ export function getStatusColor(code: number): string {
|
||||
* Get error type label
|
||||
*/
|
||||
export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
|
||||
const isZh = getFormattingLocale(locale) === 'zh-CN';
|
||||
const labels: Record<string, string> = isZh
|
||||
? {
|
||||
rate_limit: '限流',
|
||||
auth: '认证错误',
|
||||
not_found: '未找到',
|
||||
server: '服务错误',
|
||||
timeout: '超时',
|
||||
unknown: '错误',
|
||||
}
|
||||
: {
|
||||
rate_limit: 'Rate Limited',
|
||||
auth: 'Auth Error',
|
||||
not_found: 'Not Found',
|
||||
server: 'Server Error',
|
||||
timeout: 'Timeout',
|
||||
unknown: 'Error',
|
||||
};
|
||||
return labels[type] || (isZh ? '错误' : 'Error');
|
||||
const formatLocale = getFormattingLocale(locale);
|
||||
if (formatLocale === 'zh-CN') {
|
||||
const labels: Record<string, string> = {
|
||||
rate_limit: '限流',
|
||||
auth: '认证错误',
|
||||
not_found: '未找到',
|
||||
server: '服务错误',
|
||||
timeout: '超时',
|
||||
unknown: '错误',
|
||||
};
|
||||
return labels[type] || '错误';
|
||||
}
|
||||
if (formatLocale === 'vi') {
|
||||
const labels: Record<string, string> = {
|
||||
rate_limit: 'Quá giới hạn',
|
||||
auth: 'Lỗi xác thực',
|
||||
not_found: 'Không tìm thấy',
|
||||
server: 'Lỗi máy chủ',
|
||||
timeout: 'Hết thời gian chờ',
|
||||
unknown: 'Lỗi',
|
||||
};
|
||||
return labels[type] || 'Lỗi';
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
rate_limit: 'Rate Limited',
|
||||
auth: 'Auth Error',
|
||||
not_found: 'Not Found',
|
||||
server: 'Server Error',
|
||||
timeout: 'Timeout',
|
||||
unknown: 'Error',
|
||||
};
|
||||
return labels[type] || 'Error';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,22 +517,35 @@ export function formatQuotaResetTimestamp(
|
||||
locale?: string
|
||||
): string | null {
|
||||
if (!timestamp) return null;
|
||||
const isZh = getFormattingLocale(locale) === 'zh-CN';
|
||||
const formatLocale = getFormattingLocale(locale);
|
||||
const isZh = formatLocale === 'zh-CN';
|
||||
const isVi = formatLocale === 'vi';
|
||||
try {
|
||||
const resetDate = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = resetDate.getTime() - now.getTime();
|
||||
if (diff <= 0) return isZh ? '现在' : 'now';
|
||||
if (diff <= 0) {
|
||||
if (isZh) return '现在';
|
||||
if (isVi) return 'bây giờ';
|
||||
return 'now';
|
||||
}
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return isZh ? `${seconds}秒` : `${seconds}s`;
|
||||
if (seconds < 60) {
|
||||
if (isZh) return `${seconds}秒`;
|
||||
if (isVi) return `${seconds} giây`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return isZh ? `${mins}分钟` : `${mins}m`;
|
||||
if (isZh) return `${mins}分钟`;
|
||||
if (isVi) return `${mins} phút`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`;
|
||||
if (isVi) return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+1159
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
export const LOCALE_STORAGE_KEY = 'ccs-ui-locale';
|
||||
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const;
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi'] as const;
|
||||
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
@@ -11,6 +11,7 @@ export function isSupportedLocale(locale: string): locale is AppLocale {
|
||||
export function normalizeLocale(locale: string | null | undefined): AppLocale {
|
||||
if (!locale) return 'en';
|
||||
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
|
||||
if (locale.toLowerCase().startsWith('vi')) return 'vi';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,31 @@ import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { LanguageSwitcher } from '@/components/layout/language-switcher';
|
||||
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
|
||||
import { getInitialLocale, LOCALE_STORAGE_KEY, persistLocale } from '@/lib/locales';
|
||||
import {
|
||||
getInitialLocale,
|
||||
LOCALE_STORAGE_KEY,
|
||||
normalizeLocale,
|
||||
persistLocale,
|
||||
} from '@/lib/locales';
|
||||
|
||||
function flattenKeys(node: unknown, prefix = '', out = new Set<string>()): Set<string> {
|
||||
if (typeof node !== 'object' || node === null) return out;
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === 'string') {
|
||||
out.add(nextKey);
|
||||
continue;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
flattenKeys(value, nextKey, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectPlaceholders(text: string): string[] {
|
||||
return Array.from(text.matchAll(/\{\{[^{}]+\}\}/g), (match) => match[0]).sort();
|
||||
}
|
||||
|
||||
describe('Dashboard i18n', () => {
|
||||
const storage = new Map<string, string>();
|
||||
@@ -56,6 +80,7 @@ describe('Dashboard i18n', () => {
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByText('English')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Vietnamese')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText('Simplified Chinese'));
|
||||
@@ -66,9 +91,22 @@ describe('Dashboard i18n', () => {
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
|
||||
}, 10000);
|
||||
|
||||
it('supports Vietnamese locale in switcher and persistence', async () => {
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText('Vietnamese'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(i18n.language).toBe('vi');
|
||||
});
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'vi');
|
||||
expect(normalizeLocale('vi-VN')).toBe('vi');
|
||||
});
|
||||
|
||||
it('restores locale from persisted storage', () => {
|
||||
persistLocale('zh-CN');
|
||||
expect(getInitialLocale()).toBe('zh-CN');
|
||||
persistLocale('vi');
|
||||
expect(getInitialLocale()).toBe('vi');
|
||||
});
|
||||
|
||||
it('shows Chinese labels on translated settings tabs', async () => {
|
||||
@@ -80,4 +118,33 @@ describe('Dashboard i18n', () => {
|
||||
expect(screen.getByText('环境')).toBeInTheDocument();
|
||||
expect(screen.getByText('认证')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps vi translation keys in parity with en and preserves placeholders', () => {
|
||||
const resources = i18n.options.resources as
|
||||
| Record<string, { translation: Record<string, unknown> }>
|
||||
| undefined;
|
||||
|
||||
const enTranslation = resources?.en?.translation;
|
||||
const viTranslation = resources?.vi?.translation;
|
||||
|
||||
expect(enTranslation).toBeDefined();
|
||||
expect(viTranslation).toBeDefined();
|
||||
|
||||
const enKeys = flattenKeys(enTranslation);
|
||||
const viKeys = flattenKeys(viTranslation);
|
||||
|
||||
expect([...viKeys].sort()).toEqual([...enKeys].sort());
|
||||
|
||||
for (const key of enKeys) {
|
||||
const enValue = key
|
||||
.split('.')
|
||||
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], enTranslation);
|
||||
const viValue = key
|
||||
.split('.')
|
||||
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], viTranslation);
|
||||
|
||||
if (typeof enValue !== 'string' || typeof viValue !== 'string') continue;
|
||||
expect(collectPlaceholders(viValue)).toEqual(collectPlaceholders(enValue));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
formatQuotaResetTimestamp,
|
||||
formatRelativeTime,
|
||||
getErrorTypeLabel,
|
||||
} from '@/lib/error-log-parser';
|
||||
|
||||
describe('error-log-parser locale formatting', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('formats relative time in Vietnamese', () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
expect(formatRelativeTime(nowSeconds - 10, 'vi')).toBe('vừa xong');
|
||||
expect(formatRelativeTime(nowSeconds - 120, 'vi')).toBe('2 phút trước');
|
||||
expect(formatRelativeTime(nowSeconds - 7200, 'vi')).toBe('2 giờ trước');
|
||||
});
|
||||
|
||||
it('returns localized error labels for Vietnamese', () => {
|
||||
expect(getErrorTypeLabel('rate_limit', 'vi')).toBe('Quá giới hạn');
|
||||
expect(getErrorTypeLabel('auth', 'vi')).toBe('Lỗi xác thực');
|
||||
expect(getErrorTypeLabel('unknown', 'vi')).toBe('Lỗi');
|
||||
});
|
||||
|
||||
it('formats quota reset timestamp in Vietnamese', () => {
|
||||
expect(formatQuotaResetTimestamp('2026-01-01T00:00:45.000Z', 'vi')).toBe('45 giây');
|
||||
expect(formatQuotaResetTimestamp('2026-01-01T00:05:00.000Z', 'vi')).toBe('5 phút');
|
||||
expect(formatQuotaResetTimestamp('2026-01-01T02:05:00.000Z', 'vi')).toBe('2 giờ 5 phút');
|
||||
expect(formatQuotaResetTimestamp('2025-12-31T23:59:58.000Z', 'vi')).toBe('bây giờ');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user