fix(i18n): 繁体、港台和不带地区的中文浏览器都显示中文界面

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:22:02 +08:00
1 parent 4291d08e2b
commit 6943d9c4c6
5 files changed
+174 -45

No files matched your search

+47 -44
View File
@@ -1,4 +1,4 @@
import i18next from "i18next";
import i18next, { type InitOptions } from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enCommon from "./resources/en-US/common.json";
@@ -25,51 +25,54 @@ import zhNavigation from "./resources/zh-CN/navigation.json";
import zhBackups from "./resources/zh-CN/backups.json";
import zhSubmissions from "./resources/zh-CN/submissions.json";
import zhFiles from "./resources/zh-CN/files.json";
import { panelLanguage, SUPPORTED_LANGUAGES } from "./language";
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
"en-US": {
common: enCommon,
auth: enAuth,
dashboard: enDashboard,
servers: enServers,
account: enAccount,
admin: enAdmin,
ops: enOps,
errors: enErrors,
navigation: enNavigation,
backups: enBackups,
submissions: enSubmissions,
files: enFiles,
},
"zh-CN": {
common: zhCommon,
auth: zhAuth,
dashboard: zhDashboard,
servers: zhServers,
account: zhAccount,
admin: zhAdmin,
ops: zhOps,
errors: zhErrors,
navigation: zhNavigation,
backups: zhBackups,
submissions: zhSubmissions,
files: zhFiles,
},
// Exported so a test can boot a fresh instance with the same detection.
export const i18nOptions: InitOptions = {
resources: {
"en-US": {
common: enCommon,
auth: enAuth,
dashboard: enDashboard,
servers: enServers,
account: enAccount,
admin: enAdmin,
ops: enOps,
errors: enErrors,
navigation: enNavigation,
backups: enBackups,
submissions: enSubmissions,
files: enFiles,
},
fallbackLng: "en-US",
defaultNS: "common",
interpolation: {
escapeValue: false,
"zh-CN": {
common: zhCommon,
auth: zhAuth,
dashboard: zhDashboard,
servers: zhServers,
account: zhAccount,
admin: zhAdmin,
ops: zhOps,
errors: zhErrors,
navigation: zhNavigation,
backups: zhBackups,
submissions: zhSubmissions,
files: zhFiles,
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "felis-lang",
},
});
},
supportedLngs: [...SUPPORTED_LANGUAGES],
fallbackLng: "en-US",
defaultNS: "common",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "felis-lang",
convertDetectedLanguage: panelLanguage,
},
};
i18next.use(LanguageDetector).use(initReactI18next).init(i18nOptions);
export default i18next;
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { i18nOptions } from "./index";
import { panelLanguage } from "./language";
// boot starts a fresh instance with the panel's own options, as a first visit
// with these browser languages (and whatever is in storage) would.
async function boot(languages: string[]) {
vi.spyOn(window.navigator, "languages", "get").mockReturnValue(languages);
vi.spyOn(window.navigator, "language", "get").mockReturnValue(languages[0] ?? "");
const instance = i18next.createInstance().use(LanguageDetector);
await instance.init(i18nOptions);
return instance;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("the panel language on a first visit", () => {
it.each([
[["zh-CN"]],
[["zh-TW"]],
[["zh-HK", "en"]],
[["zh"]],
[["zh-Hant-TW"]],
// English listed after Chinese must not win.
[["zh-TW", "en-US", "en"]],
[["zh", "en-US"]],
])("reads Chinese for a browser asking %j", async (languages) => {
const i18n = await boot(languages);
expect(i18n.language).toBe("zh-CN");
expect(i18n.t("common:loading")).toBe(i18n.getFixedT("zh-CN")("common:loading"));
expect(localStorage.getItem("felis-lang")).toBe("zh-CN");
});
it.each([[["en-GB"]], [["en-US", "zh-CN"]], [["fr-FR", "de"]], [[]]])(
"reads English for a browser asking %j",
async (languages) => {
const i18n = await boot(languages);
expect(i18n.language).toBe("en-US");
},
);
it("keeps a language chosen here over the browser's", async () => {
localStorage.setItem("felis-lang", "en-US");
const i18n = await boot(["zh-CN"]);
expect(i18n.language).toBe("en-US");
});
it("maps a regional code a past visit stored onto the shipped text", async () => {
localStorage.setItem("felis-lang", "zh-TW");
const i18n = await boot(["en-US"]);
expect(i18n.language).toBe("zh-CN");
});
});
describe("panelLanguage", () => {
it("maps any Chinese or English code onto a shipped translation", () => {
expect(panelLanguage("zh_TW")).toBe("zh-CN");
expect(panelLanguage("ZH-hk")).toBe("zh-CN");
expect(panelLanguage("en")).toBe("en-US");
expect(panelLanguage("ja-JP")).toBe("ja-JP");
});
});
+17
View File
@@ -0,0 +1,17 @@
// The panel ships two translations; everything a browser reports is mapped onto
// one of them before i18next picks. Any Chinese (zh, zh-TW, zh-HK, zh-Hant, a
// stale "zh_TW" in storage) reads the zh-CN text and any English the en-US
// text. Mapping at detection matters: i18next first looks for an exact
// supported code anywhere in the browser's list, so ["zh-TW", "en-US"] would
// land on English if zh-TW reached it unmapped.
export const SUPPORTED_LANGUAGES = ["en-US", "zh-CN"] as const;
/** panelLanguage maps a detected language code onto a shipped translation, or
* returns it unchanged for i18next to pass over. */
export function panelLanguage(code: string): string {
const base = code.trim().toLowerCase().split(/[-_]/)[0];
if (base === "zh") return "zh-CN";
if (base === "en") return "en-US";
return code;
}
+1 -1
View File
@@ -38,7 +38,7 @@ export default defineConfig(async ({ mode }) => {
},
},
test: {
setupFiles: ["./vitest.setup.ts"],
setupFiles: ["./vitest.storage.ts", "./vitest.setup.ts"],
// e2e/*.spec.ts belong to Playwright (npm run test:e2e).
include: ["src/**/*.test.{ts,tsx}"],
},
+38
View File
@@ -0,0 +1,38 @@
import { beforeEach } from "vitest";
// Node 26 puts its own localStorage on globalThis, undefined unless node runs
// with --localstorage-file, and it shadows jsdom's. Component tests get an
// in-memory one so code that remembers things (the language, console history)
// runs as in a browser. This file runs before the i18n boot in vitest.setup.ts,
// because the language detector decides once whether storage works.
class MemoryStorage implements Storage {
#data = new Map<string, string>();
get length() {
return this.#data.size;
}
key(index: number) {
return [...this.#data.keys()][index] ?? null;
}
getItem(key: string) {
return this.#data.get(key) ?? null;
}
setItem(key: string, value: string) {
this.#data.set(key, String(value));
}
removeItem(key: string) {
this.#data.delete(key);
}
clear() {
this.#data.clear();
}
}
if (typeof window !== "undefined" && !globalThis.localStorage) {
Object.defineProperty(globalThis, "localStorage", {
value: new MemoryStorage(),
configurable: true,
writable: true,
});
// Each test starts empty, including of the boot's own cached language.
beforeEach(() => localStorage.clear());
}