Merge pull request #717 from TW199501/feat/i18n-ja-ko-zh-tw

feat(desktop): add Japanese, Korean, and Traditional Chinese UI locales
This commit is contained in:
程序员阿江-Relakkes 2026-06-04 23:25:00 +08:00 committed by GitHub
commit 4bea9ee687
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 5190 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

@ -51,6 +51,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

@ -1520,9 +1520,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'
}