mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
13f5736d8f
commit
0214a81e5d
78 files changed
+2423
-1833
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user