mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
commit
4bea9ee687
@ -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분 전')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.
|
||||
|
||||
1702
desktop/src/i18n/locales/jp.ts
Normal file
1702
desktop/src/i18n/locales/jp.ts
Normal file
File diff suppressed because it is too large
Load Diff
1702
desktop/src/i18n/locales/kr.ts
Normal file
1702
desktop/src/i18n/locales/kr.ts
Normal file
File diff suppressed because it is too large
Load Diff
1702
desktop/src/i18n/locales/zh-TW.ts
Normal file
1702
desktop/src/i18n/locales/zh-TW.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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분 전')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user