mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
Merge pull request #716 from kaitranntt/kai/feat/687-add-japanese-language-support
feat(i18n): add Japanese dashboard locale
This commit is contained in:
5 files changed
+1300
-41
No files matched your search
@@ -171,8 +171,7 @@ export function HistorySyncLearningMap({
|
||||
|
||||
{legacyTargetCount > 0 && (
|
||||
<div className="mt-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
|
||||
{legacyTargetCount} legacy account
|
||||
{legacyTargetCount > 1 ? 's still need' : ' still needs'} explicit confirmation.
|
||||
{t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
|
||||
+1207
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', 'vi'] as const;
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi', 'ja'] as const;
|
||||
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
@@ -10,6 +10,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('ja')) return 'ja';
|
||||
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
|
||||
if (locale.toLowerCase().startsWith('vi')) return 'vi';
|
||||
return 'en';
|
||||
|
||||
@@ -232,7 +232,7 @@ export function AccountsPage() {
|
||||
{t('accountsPage.tableScopePrefix')}
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
{t('accountsPage.tableScopeMiddle')}
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code>
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">{t('accountsTable.sync')}</code>
|
||||
{t('accountsPage.tableScopeSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { LanguageSwitcher } from '@/components/layout/language-switcher';
|
||||
import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map';
|
||||
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
|
||||
import {
|
||||
getInitialLocale,
|
||||
LOCALE_STORAGE_KEY,
|
||||
normalizeLocale,
|
||||
persistLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from '@/lib/locales';
|
||||
|
||||
function flattenKeys(node: unknown, prefix = '', out = new Set<string>()): Set<string> {
|
||||
@@ -91,22 +93,28 @@ describe('Dashboard i18n', () => {
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
|
||||
}, 10000);
|
||||
|
||||
it('supports Vietnamese locale in switcher and persistence', async () => {
|
||||
render(<LanguageSwitcher />);
|
||||
it.each([
|
||||
{ locale: 'vi', label: 'Vietnamese', browserLocale: 'vi-VN' },
|
||||
{ locale: 'ja', label: 'Japanese', browserLocale: 'ja-JP' },
|
||||
])(
|
||||
'supports $locale locale in switcher and persistence',
|
||||
async ({ locale, label, browserLocale }) => {
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText('Vietnamese'));
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText(label));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(i18n.language).toBe('vi');
|
||||
});
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'vi');
|
||||
expect(normalizeLocale('vi-VN')).toBe('vi');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(i18n.language).toBe(locale);
|
||||
});
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, locale);
|
||||
expect(normalizeLocale(browserLocale)).toBe(locale);
|
||||
}
|
||||
);
|
||||
|
||||
it('restores locale from persisted storage', () => {
|
||||
persistLocale('vi');
|
||||
expect(getInitialLocale()).toBe('vi');
|
||||
persistLocale('ja');
|
||||
expect(getInitialLocale()).toBe('ja');
|
||||
});
|
||||
|
||||
it('shows Chinese labels on translated settings tabs', async () => {
|
||||
@@ -119,32 +127,76 @@ describe('Dashboard i18n', () => {
|
||||
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;
|
||||
it('shows Japanese labels on translated settings tabs', async () => {
|
||||
await i18n.changeLanguage('ja');
|
||||
|
||||
const enTranslation = resources?.en?.translation;
|
||||
const viTranslation = resources?.vi?.translation;
|
||||
render(<TabNavigation activeTab="websearch" onTabChange={() => {}} />);
|
||||
|
||||
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));
|
||||
}
|
||||
expect(screen.getByText('Web検索')).toBeInTheDocument();
|
||||
expect(screen.getByText('環境変数')).toBeInTheDocument();
|
||||
expect(screen.getByText('思考')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Japanese history sync guidance without fallback English copy', async () => {
|
||||
await i18n.changeLanguage('ja');
|
||||
|
||||
render(
|
||||
<HistorySyncLearningMap
|
||||
isolatedCount={1}
|
||||
sharedStandardCount={2}
|
||||
deeperSharedCount={3}
|
||||
sharedGroups={['default']}
|
||||
legacyTargetCount={2}
|
||||
cliproxyCount={1}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('履歴同期の仕組み')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('1 件の CLIProxy Claude Pool アカウントは、CLIProxy ページで管理します。')
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: '詳細を表示: グループ、切り替え、レガシーポリシー' })
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('2 件のレガシーアカウントで明示的な確認がまだ必要です。')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(SUPPORTED_LOCALES.filter((locale) => locale !== 'en'))(
|
||||
'keeps %s translation keys in parity with en and preserves placeholders',
|
||||
(locale) => {
|
||||
const resources = i18n.options.resources as
|
||||
| Record<string, { translation: Record<string, unknown> }>
|
||||
| undefined;
|
||||
|
||||
const enTranslation = resources?.en?.translation;
|
||||
const localeTranslation = resources?.[locale]?.translation;
|
||||
|
||||
expect(enTranslation).toBeDefined();
|
||||
expect(localeTranslation).toBeDefined();
|
||||
|
||||
const enKeys = flattenKeys(enTranslation);
|
||||
const localeKeys = flattenKeys(localeTranslation);
|
||||
|
||||
expect([...localeKeys].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 localeValue = key
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(acc, part) => (acc as Record<string, unknown>)?.[part],
|
||||
localeTranslation
|
||||
);
|
||||
|
||||
if (typeof enValue !== 'string' || typeof localeValue !== 'string') continue;
|
||||
expect(collectPlaceholders(localeValue)).toEqual(collectPlaceholders(enValue));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user