mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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: "打开官方网站",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
}
|
||||
},
|
||||
"autoReload": "Data refreshed",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionChinese": "简体中文",
|
||||
"languageOptionTraditionalChinese": "繁體中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"languageOptionJapanese": "日本語",
|
||||
"windowBehavior": "Window Behavior",
|
||||
|
||||
@@ -542,7 +542,8 @@
|
||||
}
|
||||
},
|
||||
"autoReload": "データを更新しました",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionChinese": "简体中文",
|
||||
"languageOptionTraditionalChinese": "繁體中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"languageOptionJapanese": "日本語",
|
||||
"windowBehavior": "ウィンドウ動作",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -542,7 +542,8 @@
|
||||
}
|
||||
},
|
||||
"autoReload": "数据已刷新",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionChinese": "简体中文",
|
||||
"languageOptionTraditionalChinese": "繁體中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"languageOptionJapanese": "日本語",
|
||||
"windowBehavior": "窗口行为",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user