mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
feat(i18n): add comprehensive Vietnamese dashboard locale
This commit is contained in:
1 parent
593e7fd031
commit
7893436038
8 files changed
+1346
-34
No files matched your search
@@ -67,7 +67,7 @@ The dashboard provides visual management for all account types:
|
|||||||
- **Factory Droid**: Track Droid install location and BYOK settings health
|
- **Factory Droid**: Track Droid install location and BYOK settings health
|
||||||
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
||||||
- **Health Monitor**: Real-time status across all profiles
|
- **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**
|
**Analytics Dashboard**
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Dashboard i18n currently covers UI text rendered by React components.
|
|||||||
- Supported locales today:
|
- Supported locales today:
|
||||||
- `en` (English)
|
- `en` (English)
|
||||||
- `zh-CN` (Simplified Chinese)
|
- `zh-CN` (Simplified Chinese)
|
||||||
|
- `vi` (Vietnamese)
|
||||||
- Locale state is persisted in browser localStorage using `ccs-ui-locale`.
|
- Locale state is persisted in browser localStorage using `ccs-ui-locale`.
|
||||||
- Fallback language is `en`.
|
- Fallback language is `en`.
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ Out of scope:
|
|||||||
- Contains `resources` object with locale blocks:
|
- Contains `resources` object with locale blocks:
|
||||||
- `en.translation`
|
- `en.translation`
|
||||||
- `zh-CN.translation`
|
- `zh-CN.translation`
|
||||||
|
- `vi.translation`
|
||||||
- Uses `initReactI18next` for React integration.
|
- Uses `initReactI18next` for React integration.
|
||||||
|
|
||||||
### Locale utilities
|
### Locale utilities
|
||||||
@@ -85,7 +87,7 @@ Examples exist in sync/account counters.
|
|||||||
|
|
||||||
## Adding a New Locale
|
## 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`.
|
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`.
|
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`
|
- `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.
|
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
|
- 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`.
|
- UI locale persistence uses browser localStorage key `ccs-ui-locale`.
|
||||||
- Current supported locales are managed in `ui/src/lib/locales.ts`.
|
- Current supported locales are managed in `ui/src/lib/locales.ts`.
|
||||||
|
- Current locales: `en`, `zh-CN`, `vi`.
|
||||||
- Fallback locale is English (`en`).
|
- Fallback locale is English (`en`).
|
||||||
@@ -399,6 +399,7 @@ function getStatusText(code: number): string {
|
|||||||
export function formatRelativeTime(modifiedSeconds: number, locale?: string): string {
|
export function formatRelativeTime(modifiedSeconds: number, locale?: string): string {
|
||||||
const formatLocale = getFormattingLocale(locale);
|
const formatLocale = getFormattingLocale(locale);
|
||||||
const isZh = formatLocale === 'zh-CN';
|
const isZh = formatLocale === 'zh-CN';
|
||||||
|
const isVi = formatLocale === 'vi';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const modified = modifiedSeconds * 1000; // Convert to milliseconds
|
const modified = modifiedSeconds * 1000; // Convert to milliseconds
|
||||||
const diff = now - modified;
|
const diff = now - modified;
|
||||||
@@ -408,10 +409,26 @@ export function formatRelativeTime(modifiedSeconds: number, locale?: string): st
|
|||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
if (seconds < 60) return isZh ? '刚刚' : 'just now';
|
if (seconds < 60) {
|
||||||
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
|
if (isZh) return '刚刚';
|
||||||
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
|
if (isVi) return 'vừa xong';
|
||||||
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
|
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
|
// Format as date for older logs
|
||||||
const date = new Date(modified);
|
const date = new Date(modified);
|
||||||
@@ -441,25 +458,39 @@ export function getStatusColor(code: number): string {
|
|||||||
* Get error type label
|
* Get error type label
|
||||||
*/
|
*/
|
||||||
export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
|
export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
|
||||||
const isZh = getFormattingLocale(locale) === 'zh-CN';
|
const formatLocale = getFormattingLocale(locale);
|
||||||
const labels: Record<string, string> = isZh
|
if (formatLocale === 'zh-CN') {
|
||||||
? {
|
const labels: Record<string, string> = {
|
||||||
rate_limit: '限流',
|
rate_limit: '限流',
|
||||||
auth: '认证错误',
|
auth: '认证错误',
|
||||||
not_found: '未找到',
|
not_found: '未找到',
|
||||||
server: '服务错误',
|
server: '服务错误',
|
||||||
timeout: '超时',
|
timeout: '超时',
|
||||||
unknown: '错误',
|
unknown: '错误',
|
||||||
}
|
};
|
||||||
: {
|
return labels[type] || '错误';
|
||||||
rate_limit: 'Rate Limited',
|
}
|
||||||
auth: 'Auth Error',
|
if (formatLocale === 'vi') {
|
||||||
not_found: 'Not Found',
|
const labels: Record<string, string> = {
|
||||||
server: 'Server Error',
|
rate_limit: 'Quá giới hạn',
|
||||||
timeout: 'Timeout',
|
auth: 'Lỗi xác thực',
|
||||||
unknown: 'Error',
|
not_found: 'Không tìm thấy',
|
||||||
};
|
server: 'Lỗi máy chủ',
|
||||||
return labels[type] || (isZh ? '错误' : 'Error');
|
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
|
locale?: string
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!timestamp) return 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 {
|
try {
|
||||||
const resetDate = new Date(timestamp);
|
const resetDate = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = resetDate.getTime() - now.getTime();
|
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);
|
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) {
|
if (seconds < 3600) {
|
||||||
const mins = Math.floor(seconds / 60);
|
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 hours = Math.floor(seconds / 3600);
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`;
|
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`;
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 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];
|
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 {
|
export function normalizeLocale(locale: string | null | undefined): AppLocale {
|
||||||
if (!locale) return 'en';
|
if (!locale) return 'en';
|
||||||
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
|
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
|
||||||
|
if (locale.toLowerCase().startsWith('vi')) return 'vi';
|
||||||
return 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,31 @@ import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
|||||||
import i18n from '@/lib/i18n';
|
import i18n from '@/lib/i18n';
|
||||||
import { LanguageSwitcher } from '@/components/layout/language-switcher';
|
import { LanguageSwitcher } from '@/components/layout/language-switcher';
|
||||||
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
|
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', () => {
|
describe('Dashboard i18n', () => {
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
@@ -56,6 +80,7 @@ describe('Dashboard i18n', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
expect(screen.getByText('English')).toBeInTheDocument();
|
expect(screen.getByText('English')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Vietnamese')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('combobox'));
|
await userEvent.click(screen.getByRole('combobox'));
|
||||||
await userEvent.click(await screen.findByText('Simplified Chinese'));
|
await userEvent.click(await screen.findByText('Simplified Chinese'));
|
||||||
@@ -66,9 +91,22 @@ describe('Dashboard i18n', () => {
|
|||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
|
||||||
}, 10000);
|
}, 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', () => {
|
it('restores locale from persisted storage', () => {
|
||||||
persistLocale('zh-CN');
|
persistLocale('vi');
|
||||||
expect(getInitialLocale()).toBe('zh-CN');
|
expect(getInitialLocale()).toBe('vi');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Chinese labels on translated settings tabs', async () => {
|
it('shows Chinese labels on translated settings tabs', async () => {
|
||||||
@@ -80,4 +118,33 @@ describe('Dashboard i18n', () => {
|
|||||||
expect(screen.getByText('环境')).toBeInTheDocument();
|
expect(screen.getByText('环境')).toBeInTheDocument();
|
||||||
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