feat(desktop): add Japanese, Korean, and Traditional Chinese locales

Add jp / kr / zh-TW translations and wire them into the i18n runtime so the
desktop app can switch among English, Simplified Chinese, Traditional Chinese,
Japanese, and Korean.

- new locale files jp.ts / kr.ts / zh-TW.ts with full TranslationKey coverage
- register the locales in the i18n index and extend the Locale union
- accept the new codes in settingsStore.getStoredLocale (default stays zh)
- add language-switcher entries (简体中文 / 繁體中文 / 日本語 / 한국어)
- localize chat message timestamps: Han 年月日 for zh/zh-TW/jp, Intl ko-KR for kr
- extend the DATE_LOCALES map in ActivitySettings (ja-JP / ko-KR / zh-TW)
- tests: formatMessageTimestamp locale branches + i18n locale resolution

Korean terminology follows the Microsoft Korean localization style guide
(새로 고침 / 사용·사용 안 함 / 이름 바꾸기, 합니다-style sentences).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tw199501 2026-06-03 09:55:38 +08:00
parent ebc78befbe
commit f02ea93018
10 changed files with 5184 additions and 10 deletions

View File

@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
import { useTranslation } from '.'
import { translate, useTranslation } from '.'
describe('useTranslation', () => {
afterEach(() => {
@ -26,4 +26,17 @@ describe('useTranslation', () => {
})
expect(result.current).not.toBe(initial)
})
it('resolves every registered locale to its own translation', () => {
expect(translate('en', 'common.save')).toBe('Save')
expect(translate('zh', 'common.save')).toBe('保存')
expect(translate('zh-TW', 'common.save')).toBe('儲存')
expect(translate('jp', 'common.save')).toBe('保存')
expect(translate('kr', 'common.save')).toBe('저장')
})
it('interpolates params across the new locales', () => {
expect(translate('jp', 'session.timeMinutes', { n: 5 })).toBe('5 分前')
expect(translate('kr', 'session.timeMinutes', { n: 5 })).toBe('5분 전')
})
})

View File

@ -2,10 +2,19 @@ import { useCallback } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { en, type TranslationKey } from './locales/en'
import { zh } from './locales/zh'
import { zh as zhTW } from './locales/zh-TW'
import { jp } from './locales/jp'
import { kr } from './locales/kr'
export type Locale = 'en' | 'zh'
export type Locale = 'en' | 'zh' | 'zh-TW' | 'jp' | 'kr'
const translations: Record<Locale, Record<string, string>> = { en, zh }
const translations: Record<Locale, Record<string, string>> = {
en,
zh,
'zh-TW': zhTW,
jp,
kr,
}
/**
* Translate a key with optional interpolation params.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -26,4 +26,34 @@ describe('formatMessageTimestamp', () => {
expect(formatMessageTimestamp(value, t('zh'), 'zh', now)).toBe('4月20日 09:30')
})
it('formats Japanese history with Han year/month/day characters', () => {
const weekday = new Date(2026, 4, 24, 15, 50).getTime()
expect(formatMessageTimestamp(weekday, t('jp'), 'jp', now)).toContain('15:50')
const monthDay = new Date(2026, 3, 20, 9, 30).getTime()
expect(formatMessageTimestamp(monthDay, t('jp'), 'jp', now)).toBe('4月20日 09:30')
const yearMonthDay = new Date(2025, 11, 15, 9, 30).getTime()
expect(formatMessageTimestamp(yearMonthDay, t('jp'), 'jp', now)).toBe('2025年12月15日 09:30')
expect(formatMessageTimestamp(now - 5 * 60_000, t('jp'), 'jp', now)).toBe('5 分前')
})
it('formats Traditional Chinese history with Han year/month/day characters', () => {
const monthDay = new Date(2026, 3, 20, 9, 30).getTime()
expect(formatMessageTimestamp(monthDay, t('zh-TW'), 'zh-TW', now)).toBe('4月20日 09:30')
const yearMonthDay = new Date(2025, 11, 15, 9, 30).getTime()
expect(formatMessageTimestamp(yearMonthDay, t('zh-TW'), 'zh-TW', now)).toBe('2025年12月15日 09:30')
})
it('formats Korean history via Intl (ko-KR) without Han characters', () => {
const monthDay = new Date(2026, 3, 20, 9, 30).getTime()
const out = formatMessageTimestamp(monthDay, t('kr'), 'kr', now)
expect(out).toContain('09:30')
expect(out).not.toContain('月') // Korean uses 월, never the Han 月
expect(formatMessageTimestamp(now - 5 * 60_000, t('kr'), 'kr', now)).toBe('5분 전')
})
})

View File

@ -52,19 +52,32 @@ function coerceDate(value: number | string | Date): Date | null {
}
function localeToIntl(locale: Locale): string {
return locale === 'zh' ? 'zh-CN' : 'en-US'
switch (locale) {
case 'zh': return 'zh-CN'
case 'zh-TW': return 'zh-TW'
case 'jp': return 'ja-JP'
case 'kr': return 'ko-KR'
default: return 'en-US'
}
}
// Locales whose dates use the 年/月/日 Han characters: Chinese (Simplified/Traditional)
// and Japanese share identical glyphs. Korean uses 년/월/일, so it goes through Intl instead.
function usesHanYmd(locale: Locale): boolean {
return locale === 'zh' || locale === 'zh-TW' || locale === 'jp'
}
function formatWeekdayTime(date: Date, locale: Locale): string {
const intlLocale = localeToIntl(locale)
const weekday = new Intl.DateTimeFormat(intlLocale, { weekday: locale === 'zh' ? 'long' : 'short' }).format(date)
const han = usesHanYmd(locale)
const weekday = new Intl.DateTimeFormat(intlLocale, { weekday: han ? 'long' : 'short' }).format(date)
const time = formatClockTime(date, intlLocale)
return locale === 'zh' ? `${weekday}${time}` : `${weekday} ${time}`
return han ? `${weekday}${time}` : `${weekday} ${time}`
}
function formatMonthDayTime(date: Date, locale: Locale): string {
const intlLocale = localeToIntl(locale)
if (locale === 'zh') {
if (usesHanYmd(locale)) {
return `${date.getMonth() + 1}${date.getDate()}${formatClockTime(date, intlLocale)}`
}
const day = new Intl.DateTimeFormat(intlLocale, {
@ -76,7 +89,7 @@ function formatMonthDayTime(date: Date, locale: Locale): string {
function formatYearMonthDayTime(date: Date, locale: Locale): string {
const intlLocale = localeToIntl(locale)
if (locale === 'zh') {
if (usesHanYmd(locale)) {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${formatClockTime(date, intlLocale)}`
}
const day = new Intl.DateTimeFormat(intlLocale, {

View File

@ -50,6 +50,9 @@ const HEAT_COLORS = [
const DATE_LOCALES: Record<Locale, string> = {
en: 'en-US',
zh: 'zh-CN',
'zh-TW': 'zh-TW',
jp: 'ja-JP',
kr: 'ko-KR',
}
const DEFAULT_PROFILE: DesktopProfilePreferences = {
displayName: 'cc-haha',

View File

@ -1517,9 +1517,13 @@ function GeneralSettings() {
const LANGUAGES: Array<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
{ value: 'zh', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'jp', label: '日本語' },
{ value: 'kr', label: '한국어' },
]
const RESPONSE_LANGUAGES: Array<{ value: string; label: string }> = [
{ value: '', label: t('settings.general.responseLangDefault') },
{ value: 'english', label: 'English' },

View File

@ -41,10 +41,12 @@ export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
const VALID_LOCALES: readonly Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']
function getStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored === 'en' || stored === 'zh') return stored
if (stored && (VALID_LOCALES as readonly string[]).includes(stored)) return stored as Locale
} catch { /* localStorage unavailable */ }
return 'zh'
}