Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/components/console/project/add-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default function AddProjectDialog({
const [name, setName] = useState("")
const [selectedSource, setSelectedSource] = useState<string>("")
const [selectedRepoValue, setSelectedRepoValue] = useState("")
// 持久化已选仓库,避免分页/搜索后当前页不含它时标签丢失
// Keep the selected repository visible when pagination or search removes it from the current page.
const [selectedRepoOption, setSelectedRepoOption] = useState<RepoOption | null>(null)
const [repoPopoverOpen, setRepoPopoverOpen] = useState(false)
const [loading, setLoading] = useState(false)
Expand Down Expand Up @@ -112,7 +112,7 @@ export default function AddProjectDialog({
const identityLabel = (identity: DomainGitIdentity) =>
identity.remark || identity.username || identity.base_url || t("consoleProject.create.unnamedIdentity")

// 切换身份时清空已选仓库(仓库列表由 useIdentityRepos 自动按身份重新拉取)
// Clear the repository when the identity changes; the hook reloads repositories for that identity.
useEffect(() => {
setSelectedRepoValue("")
setSelectedRepoOption(null)
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/language-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Languages } from "lucide-react"
import { useTranslation } from "react-i18next"

import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { setAppLanguage } from "@/i18n"
import { isAppLanguage, type AppLanguage } from "@/i18n/language"

export function LanguageToggle() {
const { i18n, t } = useTranslation()
const currentLanguage: AppLanguage = isAppLanguage(i18n.resolvedLanguage)
? i18n.resolvedLanguage
: "en"

const changeLanguage = (language: string) => {
if (isAppLanguage(language) && language !== currentLanguage) {
void setAppLanguage(language)
}
}

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
aria-label={t("common.language.switch")}
className="min-w-9"
>
<Languages aria-hidden="true" />
<span className="hidden sm:inline">
{t(`common.language.${currentLanguage}`)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuLabel>{t("common.language.switch")}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={currentLanguage}
onValueChange={changeLanguage}
>
<DropdownMenuRadioItem value="cn">
{t("common.language.cn")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="en">
{t("common.language.en")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
7 changes: 6 additions & 1 deletion frontend/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import i18n from "i18next"
import { initReactI18next } from "react-i18next"

import { patchDocumentMeta } from "./document-meta"
import { initLanguage, type AppLanguage } from "./language"
import { applyLanguage, initLanguage, type AppLanguage } from "./language"
import cn from "./resources/cn"
import en from "./resources/en"

Expand Down Expand Up @@ -34,6 +34,11 @@ export async function initI18n(language: AppLanguage = initLanguage()) {
syncDocumentMeta()
}

export async function setAppLanguage(language: AppLanguage) {
applyLanguage(language)
await initI18n(language)
}

function syncDocumentMeta() {
patchDocumentMeta((key) => String(i18n.t(key)))
}
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/i18n/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,13 @@ export function writeLanguageCookie(language: AppLanguage) {
document.cookie = buildLanguageCookie(language)
}

export function applyLanguage(language: AppLanguage) {
writeLanguageCookie(language)
export function applyLanguage(
language: AppLanguage,
{ persist = true }: { persist?: boolean } = {},
) {
if (persist) {
writeLanguageCookie(language)
}
dayjs.locale(getDayjsLocale(language))

if (typeof document !== "undefined") {
Expand All @@ -90,7 +95,7 @@ export function applyLanguage(language: AppLanguage) {

export function initLanguage(): AppLanguage {
const language = resolveInitialLanguage(readLanguageCookie(), getBrowserLanguage())
applyLanguage(language)
applyLanguage(language, { persist: false })
return language
}

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/resources/cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const cn = {
},
},
common: {
language: {
switch: "切换语言",
cn: "中文",
en: "English",
},
theme: {
toggle: "切换主题",
light: "明亮模式",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const en = {
},
},
common: {
language: {
switch: "Switch language",
cn: "中文",
en: "English",
},
theme: {
toggle: "Toggle theme",
light: "Light mode",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/console/manager/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"
import { RefreshCw } from "lucide-react"
import ManagerSidebar from "@/components/manager/manager-sidebar"
import { Fragment } from "react/jsx-runtime"
import { LanguageToggle } from "@/components/language-toggle"
import { ModeToggle } from "@/components/mode-toggle"
import { useTranslation } from "react-i18next"

Expand Down Expand Up @@ -129,6 +130,7 @@ export default function ManagerConsolePage() {
<RefreshCw className="h-[1.2rem] w-[1.2rem]" />
{t("managerShell.actions.refresh")}
</Button>
<LanguageToggle />
<ModeToggle />
</div>
</header>
Expand Down
38 changes: 38 additions & 0 deletions frontend/test/i18n-language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,52 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
applyLanguage,
buildLanguageCookie,
detectLanguageFromBrowser,
getDayjsLocale,
getHtmlLang,
isAppLanguage,
initLanguage,
resolveInitialLanguage,
} from "../src/i18n/language.ts";

test("自动检测浏览器语言时不持久化,手动选择后才写入 cookie", () => {
const documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, "document");
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
const fakeDocument = { cookie: "", documentElement: { lang: "" } };

Object.defineProperty(globalThis, "document", {
configurable: true,
value: fakeDocument,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { language: "zh-CN" },
});

try {
assert.equal(initLanguage(), "cn");
assert.equal(fakeDocument.cookie, "");
assert.equal(fakeDocument.documentElement.lang, "zh-CN");

applyLanguage("en");
assert.match(fakeDocument.cookie, /^language=en;/);
assert.equal(fakeDocument.documentElement.lang, "en");
} finally {
if (documentDescriptor) {
Object.defineProperty(globalThis, "document", documentDescriptor);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (navigatorDescriptor) {
Object.defineProperty(globalThis, "navigator", navigatorDescriptor);
} else {
delete (globalThis as { navigator?: unknown }).navigator;
}
}
});

test("语言 cookie 只接受 cn 和 en", () => {
assert.equal(isAppLanguage("cn"), true);
assert.equal(isAppLanguage("en"), true);
Expand Down
38 changes: 38 additions & 0 deletions frontend/test/i18n-resources-completeness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict"
import test from "node:test"

import cn from "../src/i18n/resources/cn.ts"
import en from "../src/i18n/resources/en.ts"

function collectLeaves(value: unknown, path = "", leaves = new Map<string, string>()) {
if (typeof value === "string") {
leaves.set(path, value)
return leaves
}
if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
collectLeaves(child, path ? `${path}.${key}` : key, leaves)
}
}
return leaves
}

test("中英文资源结构保持完整一致", () => {
const cnLeaves = collectLeaves(cn)
const enLeaves = collectLeaves(en)

assert.deepEqual([...enLeaves.keys()].sort(), [...cnLeaves.keys()].sort())
})

test("英文资源不包含未翻译中文", () => {
const allowedChineseIdentifiers = new Set([
"common.language.cn",
"welcomeShell.footer.icp",
])

for (const [key, value] of collectLeaves(en)) {
if (!allowedChineseIdentifiers.has(key)) {
assert.doesNotMatch(value, /[\u3400-\u9fff]/, `${key} contains untranslated Chinese`)
}
}
})
6 changes: 3 additions & 3 deletions frontend/test/login-i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ test("登录页使用 i18n 翻译 key 渲染主要文案", () => {
assert.match(loginSource, /t\("login\.toast\.missingCredentials"\)/);
});

test("登录页根据服务端 region=en 展示海外登录入口", () => {
test("登录页根据服务端 region=global 展示海外登录入口", () => {
assert.match(loginSource, /useAppRuntime/);
assert.match(loginSource, /serverRegion === "en"/);
assert.match(loginSource, /!isEnRegion/);
assert.match(loginSource, /serverRegion === "global"/);
assert.match(loginSource, /!IS_OFFLINE_EDITION && isGlobalRegion/);
});

test("登录页翻译资源提供中英文文案", () => {
Expand Down
43 changes: 43 additions & 0 deletions frontend/test/manager-all-pages-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from "node:assert/strict"
import { readdirSync, readFileSync } from "node:fs"
import { extname, join } from "node:path"
import { fileURLToPath } from "node:url"
import test from "node:test"

const roots = [
fileURLToPath(new URL("../src/pages/console/manager", import.meta.url)),
fileURLToPath(new URL("../src/components/manager", import.meta.url)),
]

function sourceFiles(root: string): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name)
if (entry.isDirectory()) return sourceFiles(path)
return [".ts", ".tsx"].includes(extname(entry.name)) ? [path] : []
})
}

test("管理后台所有页面和组件不包含硬编码中文 label", () => {
for (const path of roots.flatMap(sourceFiles)) {
const source = readFileSync(path, "utf8")
assert.doesNotMatch(source, /[\u3400-\u9fff]/, `${path} contains hard-coded Chinese`)
}
})

test("管理后台统一挂载可持久化语言切换入口", () => {
const shell = readFileSync(
new URL("../src/pages/console/manager/page.tsx", import.meta.url),
"utf8",
)
const toggle = readFileSync(
new URL("../src/components/language-toggle.tsx", import.meta.url),
"utf8",
)
const i18n = readFileSync(new URL("../src/i18n/index.ts", import.meta.url), "utf8")

assert.match(shell, /<LanguageToggle \/>/)
assert.match(toggle, /DropdownMenuRadioGroup/)
assert.match(toggle, /common\.language\.cn/)
assert.match(toggle, /common\.language\.en/)
assert.match(i18n, /applyLanguage\(language\)/)
})
6 changes: 6 additions & 0 deletions frontend/test/manager-shell-i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import cn from "../src/i18n/resources/cn.ts";
import en from "../src/i18n/resources/en.ts";

const sourceFiles = {
languageToggle: readSource("../src/components/language-toggle.tsx"),
sidebar: readSource("../src/components/manager/manager-sidebar.tsx"),
navTeams: readSource("../src/components/manager/nav-teams.tsx"),
navManager: readSource("../src/components/manager/nav-manager.tsx"),
Expand All @@ -23,17 +24,22 @@ test("管理后台外壳使用 managerShell i18n key", () => {
assert.match(source, /useTranslation/);
}

assert.match(sourceFiles.languageToggle, /t\("common\.language\.switch"\)/);
assert.match(sourceFiles.languageToggle, /setAppLanguage\(language\)/);
assert.match(sourceFiles.sidebar, /t\("managerShell\.brand\.subtitle"\)/);
assert.match(sourceFiles.navTeams, /t\("managerShell\.nav\.overview"\)/);
assert.match(sourceFiles.navTeams, /t\("managerShell\.nav\.members"\)/);
assert.match(sourceFiles.navManager, /t\("managerShell\.account\.changePassword\.title"\)/);
assert.match(sourceFiles.navManager, /t\("managerShell\.account\.logout\.confirmTitle"\)/);
assert.match(sourceFiles.page, /<LanguageToggle \/>/);
assert.match(sourceFiles.page, /t\("managerShell\.actions\.refresh"\)/);
assert.match(sourceFiles.page, /t\("managerShell\.breadcrumb\.fallback"\)/);
assert.doesNotMatch(combinedSource, cjkPattern);
});

test("管理后台外壳提供中英文资源", () => {
assert.equal(cn.common.language.switch, "切换语言");
assert.equal(en.common.language.switch, "Switch language");
assert.equal(cn.managerShell.nav.overview, "仪表盘");
assert.equal(en.managerShell.nav.overview, "Dashboard");
assert.equal(cn.managerShell.account.changePassword.title, "修改密码");
Expand Down
Loading