feat(i18n): add Traditional Chinese localization (#3093)

* Add Traditional Chinese localization

* fix: address zh-TW formatting and token units

- Format `zh-TW.json` with Prettier.
- Use Traditional Chinese `萬` and `億` units for zh-TW token summaries.
- Add usage formatting coverage for Traditional Chinese locale aliases.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
滅ü
2026-05-27 00:05:03 +08:00
committed by GitHub
Unverified
parent 8cdaf90d8d
commit 5fd3ec0d6a
16 changed files with 2795 additions and 21 deletions
+1 -1
View File
@@ -443,7 +443,7 @@ impl AppSettings {
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
.filter(|s| matches!(*s, "en" | "zh" | "zh-TW" | "ja"))
.map(|s| s.to_string());
if let Some(sync) = &mut self.webdav_sync {
+8
View File
@@ -47,6 +47,14 @@ impl TrayTexts {
quit: "終了",
_auto_label: "自動 (フェイルオーバー)",
},
"zh-TW" => Self {
show_main: "開啟主介面",
open_website: "開啟官方網站",
no_providers_label: "(無供應商)",
lightweight_mode: "輕量模式",
quit: "退出",
_auto_label: "自動 (故障轉移)",
},
_ => Self {
show_main: "打开主界面",
open_website: "打开官方网站",
+7 -1
View File
@@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type LanguageOption = "zh" | "en" | "ja";
type LanguageOption = "zh" | "zh-TW" | "en" | "ja";
interface LanguageSettingsProps {
value: LanguageOption;
@@ -24,6 +24,12 @@ export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
<LanguageButton active={value === "zh"} onClick={() => onChange("zh")}>
{t("settings.languageOptionChinese")}
</LanguageButton>
<LanguageButton
active={value === "zh-TW"}
onClick={() => onChange("zh-TW")}
>
{t("settings.languageOptionTraditionalChinese")}
</LanguageButton>
<LanguageButton active={value === "en"} onClick={() => onChange("en")}>
{t("settings.languageOptionEnglish")}
</LanguageButton>
+5 -3
View File
@@ -22,9 +22,11 @@ export function RequestDetailPanel({
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
: i18n.language === "zh-TW"
? "zh-TW"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
if (isLoading) {
return (
+27 -3
View File
@@ -31,10 +31,28 @@ export function fmtUsd(
return `$${num.toFixed(digits)}`;
}
function normalizeLanguageTag(language: string): string {
return language.toLowerCase().replace(/_/g, "-");
}
function isTraditionalChineseLanguage(normalizedLanguage: string): boolean {
return (
normalizedLanguage === "zh-tw" ||
normalizedLanguage.startsWith("zh-hant") ||
normalizedLanguage.startsWith("zh-hk") ||
normalizedLanguage.startsWith("zh-mo")
);
}
export function getLocaleFromLanguage(language: string): string {
if (!language) return "en-US";
if (language.startsWith("zh")) return "zh-CN";
if (language.startsWith("ja")) return "ja-JP";
const normalized = normalizeLanguageTag(language);
if (normalized === "zh") return "zh-CN";
if (isTraditionalChineseLanguage(normalized)) {
return "zh-TW";
}
if (normalized.startsWith("zh")) return "zh-CN";
if (normalized.startsWith("ja")) return "ja-JP";
return "en-US";
}
@@ -61,7 +79,13 @@ export function formatTokensShort(
): string {
if (!Number.isFinite(value) || value <= 0) return "0";
const decimals = compactDecimals;
if (lang.startsWith("zh") || lang.startsWith("ja")) {
const normalizedLang = normalizeLanguageTag(lang);
if (isTraditionalChineseLanguage(normalizedLang)) {
if (value >= 1e8) return `${(value / 1e8).toFixed(2)}`;
if (value >= 1e4) return `${(value / 1e4).toFixed(decimals)}`;
return value.toLocaleString("zh-TW");
}
if (normalizedLang.startsWith("zh") || normalizedLang.startsWith("ja")) {
if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`;
if (value >= 1e4) return `${(value / 1e4).toFixed(decimals)}`;
return value.toLocaleString();
+6 -1
View File
@@ -18,7 +18,12 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
const { t, i18n } = useTranslation();
const sortedProviders = useMemo(() => {
const locale = i18n.language === "zh" ? "zh-CN" : "en-US";
const locale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "zh-TW"
? "zh-TW"
: "en-US";
return Object.values(providers).sort((a, b) => {
if (a.sortIndex !== undefined && b.sortIndex !== undefined) {
return a.sortIndex - b.sortIndex;
+35 -5
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useSettingsQuery } from "@/lib/query";
import type { Settings } from "@/types";
type Language = "zh" | "en" | "ja";
type Language = "zh" | "zh-TW" | "en" | "ja";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
@@ -11,8 +11,38 @@ export type SettingsFormState = Omit<Settings, "language"> & {
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
const normalized = lang.toLowerCase();
return normalized === "en" || normalized === "ja" ? normalized : "zh";
const normalized = lang.toLowerCase().replace(/_/g, "-");
if (normalized === "zh") {
return "zh";
}
if (
normalized === "zh-tw" ||
normalized.startsWith("zh-hant") ||
normalized.startsWith("zh-hk") ||
normalized.startsWith("zh-mo")
) {
return "zh-TW";
}
if (normalized === "en" || normalized === "ja") {
return normalized;
}
if (normalized.startsWith("zh")) {
return "zh";
}
return "zh";
};
const isSupportedLanguage = (lang?: string | null): boolean => {
if (!lang) return false;
const normalized = lang.toLowerCase().replace(/_/g, "-");
return (
normalized === "en" || normalized === "ja" || normalized.startsWith("zh")
);
};
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -52,8 +82,8 @@ export function useSettingsForm(): UseSettingsFormResult {
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh" || stored === "ja") {
return stored as Language;
if (isSupportedLanguage(stored)) {
return normalizeLanguage(stored);
}
}
return normalizeLanguage(i18n.language);
+24 -2
View File
@@ -4,8 +4,9 @@ import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import ja from "./locales/ja.json";
import zh from "./locales/zh.json";
import zhTW from "./locales/zh-TW.json";
type Language = "zh" | "en" | "ja";
type Language = "zh" | "zh-TW" | "en" | "ja";
const DEFAULT_LANGUAGE: Language = "zh";
@@ -13,7 +14,12 @@ const getInitialLanguage = (): Language => {
if (typeof window !== "undefined") {
try {
const stored = window.localStorage.getItem("language");
if (stored === "zh" || stored === "en" || stored === "ja") {
if (
stored === "zh" ||
stored === "zh-TW" ||
stored === "en" ||
stored === "ja"
) {
return stored;
}
} catch (error) {
@@ -27,6 +33,19 @@ const getInitialLanguage = (): Language => {
navigator.languages?.[0]?.toLowerCase())
: undefined;
if (navigatorLang === "zh") {
return "zh";
}
if (
navigatorLang?.startsWith("zh-tw") ||
navigatorLang?.startsWith("zh-hk") ||
navigatorLang?.startsWith("zh-mo") ||
navigatorLang?.startsWith("zh-hant")
) {
return "zh-TW";
}
if (navigatorLang?.startsWith("zh")) {
return "zh";
}
@@ -52,6 +71,9 @@ const resources = {
zh: {
translation: zh,
},
"zh-TW": {
translation: zhTW,
},
};
i18n.use(initReactI18next).init({
+2 -1
View File
@@ -542,7 +542,8 @@
}
},
"autoReload": "Data refreshed",
"languageOptionChinese": "中文",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "Window Behavior",
+2 -1
View File
@@ -542,7 +542,8 @@
}
},
"autoReload": "データを更新しました",
"languageOptionChinese": "中文",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "ウィンドウ動作",
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -542,7 +542,8 @@
}
},
"autoReload": "数据已刷新",
"languageOptionChinese": "中文",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
"windowBehavior": "窗口行为",
+1 -1
View File
@@ -15,7 +15,7 @@ export const settingsSchema = z.object({
skipClaudeOnboarding: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
enableLocalProxy: z.boolean().optional(),
language: z.enum(["en", "zh", "ja"]).optional(),
language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(),
// 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(),
+1 -1
View File
@@ -342,7 +342,7 @@ export interface Settings {
// User has confirmed the common config first-run notice
commonConfigConfirmed?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
language?: "en" | "zh" | "zh-TW" | "ja";
// 主页面显示的应用(默认全部显示)
visibleApps?: VisibleApps;
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import {
formatTokensShort,
getLocaleFromLanguage,
} from "@/components/usage/format";
describe("usage format helpers", () => {
it("formats Traditional Chinese token units with Traditional characters", () => {
expect(formatTokensShort(12_345, "zh-TW")).toBe("1.2 萬");
expect(formatTokensShort(123_456_789, "zh-Hant", 2)).toBe("1.23 億");
});
it("resolves Traditional Chinese locale aliases", () => {
expect(getLocaleFromLanguage("zh_TW")).toBe("zh-TW");
expect(getLocaleFromLanguage("zh-HK")).toBe("zh-TW");
});
});
+23
View File
@@ -81,6 +81,29 @@ describe("useSettingsForm Hook", () => {
expect(changeLanguageSpy).toHaveBeenCalledWith("ja");
});
it("should support traditional chinese language preference aliases", async () => {
useSettingsQueryMock.mockReturnValue({
data: {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
claudeConfigDir: "/Users/demo",
codexConfigDir: null,
language: "zh-Hant",
},
isLoading: false,
});
const { result } = renderHook(() => useSettingsForm());
await waitFor(() => {
expect(result.current.settings?.language).toBe("zh-TW");
});
expect(result.current.initialLanguage).toBe("zh-TW");
expect(changeLanguageSpy).toHaveBeenCalledWith("zh-TW");
});
it("should prioritize reading language from local storage in readPersistedLanguage", () => {
useSettingsQueryMock.mockReturnValue({
data: null,