fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-03 21:37:00 +08:00
1 parent 13f5736d8f
commit 0214a81e5d
78 files changed
+2423 -1833

No files matched your search

+7 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
@@ -53,6 +54,8 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { t } = useTranslation();
return (
<DialogPortal>
<DialogOverlay />
@@ -73,7 +76,7 @@ function DialogContent({
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</Button>
</DialogPrimitive.Close>
)}
@@ -100,6 +103,8 @@ function DialogFooter({
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
const { t } = useTranslation();
return (
<div
data-slot="dialog-footer"
@@ -112,7 +117,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t("common.close")}</Button>
</DialogPrimitive.Close>
)}
</div>
+1 -1
View File
@@ -188,7 +188,7 @@ function Select2({
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search..."
placeholder={t("common.search")}
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoFocus
/>
+4 -1
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
@@ -53,6 +54,8 @@ function SheetContent({
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
const { t } = useTranslation();
return (
<SheetPortal>
<SheetOverlay />
@@ -74,7 +77,7 @@ function SheetContent({
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</Button>
</SheetPrimitive.Close>
)}
+11 -5
View File
@@ -1,6 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
"use client";
import { useTranslation } from "react-i18next";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
@@ -159,6 +160,7 @@ function Sidebar({
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useTranslation();
if (collapsible === "none") {
return (
@@ -192,8 +194,10 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>{t("common.sidebar")}</SheetTitle>
<SheetDescription>
{t("common.mobileSidebarDescription")}
</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -253,6 +257,7 @@ function SidebarTrigger({
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
const { t } = useTranslation();
return (
<Button
@@ -268,22 +273,23 @@ function SidebarTrigger({
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">{t("common.toggleSidebar")}</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
const { t } = useTranslation();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={t("common.toggleSidebar")}
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title={t("common.toggleSidebar")}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
+12 -2
View File
@@ -189,7 +189,11 @@
"remove": "Remove",
"add": "Add",
"update": "Update",
"noResults": "No results found"
"noResults": "No results found",
"sidebar": "Sidebar",
"mobileSidebarDescription": "Displays the mobile sidebar.",
"toggleSidebar": "Toggle Sidebar",
"search": "Search..."
},
"onboarding": {
"stepCounter": "Step {{current}} of {{total}}",
@@ -1122,7 +1126,13 @@
"language": "Language",
"keyPassword": "key password",
"pastePrivateKey": "Paste your private key here...",
"enterPassword": "Enter your password"
"enterPassword": "Enter your password",
"apiKeyName": "e.g., CI Pipeline",
"credentialName": "e.g. Production SSH Key",
"optionalDetails": "Optional details...",
"sshUsername": "e.g. root or deploy",
"hostAddress": "10.0.0.1 or example.com",
"hostName": "e.g. Web Server Production"
},
"dashboard": {
"title": "Dashboard",
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -77,9 +77,12 @@ export function FeatureSettingsSection({
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={plugin.name}
label={t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
})}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
@@ -206,7 +209,11 @@ function FeatureSettingsForm({
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.featureUnavailable", { name: plugin.name })}
{t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
})}
</div>
)}
+3 -1
View File
@@ -646,7 +646,9 @@ export function CommandPalette({
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<LifeBuoy className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Support</span>
<span className="text-sm font-medium">
{t("dashboard.support")}
</span>
</CommandItem>
</div>
</CommandGroup>
+1 -1
View File
@@ -150,7 +150,7 @@ export function AdminApiKeysSection({
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., CI Pipeline"
placeholder={t("placeholders.apiKeyName")}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="text-xs"
+3 -3
View File
@@ -183,7 +183,7 @@ export function CredentialEditorView({
{t("hosts.friendlyNameLabel")}
</label>
<Input
placeholder="e.g. Production SSH Key"
placeholder={t("placeholders.credentialName")}
value={credForm.name}
onChange={(e) => setCredField("name", e.target.value)}
/>
@@ -203,7 +203,7 @@ export function CredentialEditorView({
{t("hosts.descriptionLabel")}
</label>
<Input
placeholder="Optional details..."
placeholder={t("placeholders.optionalDetails")}
value={credForm.description}
onChange={(e) => setCredField("description", e.target.value)}
/>
@@ -278,7 +278,7 @@ export function CredentialEditorView({
{t("hosts.username")}
</label>
<Input
placeholder="e.g. root or deploy"
placeholder={t("placeholders.sshUsername")}
value={credForm.username}
onChange={(e) => setCredField("username", e.target.value)}
/>
+1 -1
View File
@@ -1444,7 +1444,7 @@ export function HostEditor({
{t("hosts.friendlyNameLabel")}
</label>
<Input
placeholder="e.g. Production SSH Key"
placeholder={t("placeholders.credentialName")}
value={quickCredentialName}
onChange={(e) => setQuickCredentialName(e.target.value)}
/>
+2 -2
View File
@@ -208,7 +208,7 @@ export function HostEditorGeneralTab({
{t("hosts.addressIp")}
</label>
<Input
placeholder="10.0.0.1 or example.com"
placeholder={t("placeholders.hostAddress")}
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
@@ -220,7 +220,7 @@ export function HostEditorGeneralTab({
{t("hosts.friendlyName")}
</label>
<Input
placeholder="e.g. Web Server Production"
placeholder={t("placeholders.hostName")}
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
@@ -23,8 +23,15 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
}));
vi.mock("react-i18next", () => {
const t = (key: string, options?: { name?: string }) =>
options?.name ? `${key}:${options.name}` : key;
const t = (
key: string,
options?: { name?: string; defaultValue?: string },
) =>
key === "ai:plugin.name"
? "AI 助手"
: options?.name
? `${key}:${options.name}`
: (options?.defaultValue ?? key);
return { useTranslation: () => ({ t }) };
});
@@ -112,10 +119,22 @@ describe("FeatureSettingsSection", () => {
/>,
);
expect(screen.getByText("AI Assistant")).toBeTruthy();
expect(screen.getByText("AI 助手")).toBeTruthy();
expect(api.getPluginUserSettings).not.toHaveBeenCalled();
});
it("falls back to the manifest name for plugins without a translated title", () => {
render(
<FeatureSettingsSection
plugin={plugin({ id: "third-party", name: "Custom Feature" })}
scope="admin"
open={false}
onToggle={vi.fn()}
/>,
);
expect(screen.getByText("Custom Feature")).toBeTruthy();
});
it("loads and saves the scope's values once open", async () => {
api.getPluginUserSettings.mockResolvedValue({ enabled: false });
api.updatePluginUserSettings.mockResolvedValue({ enabled: true });
@@ -179,7 +198,7 @@ describe("FeatureSettingsSection", () => {
);
expect(
await screen.findByText("settings.featureUnavailable:AI Assistant"),
await screen.findByText("settings.featureUnavailable:AI 助手"),
).toBeTruthy();
});
});