From 2f53e14a2632e882317fb2572151adc4f6aca253 Mon Sep 17 00:00:00 2001 From: bdim404 Date: Thu, 5 Feb 2026 13:59:13 +0800 Subject: [PATCH] feat: Support i18n --- .../@/components/ui/dropdown-menu.tsx | 198 ++++++++++++++++++ qwen3-tts-frontend/package-lock.json | 187 ++++++++++++++++- qwen3-tts-frontend/package.json | 4 + .../src/components/AudioPlayer.tsx | 6 +- .../src/components/AudioRecorder.tsx | 14 +- .../src/components/FileUploader.tsx | 8 +- .../src/components/HistoryItem.tsx | 39 ++-- .../src/components/HistorySidebar.tsx | 12 +- .../src/components/JobDetailDialog.tsx | 79 +++---- .../src/components/LoadingState.tsx | 9 +- qwen3-tts-frontend/src/components/Navbar.tsx | 36 +++- .../src/components/OnboardingDialog.tsx | 47 +++-- .../src/components/tts/CustomVoiceForm.tsx | 110 +++++----- .../src/components/tts/VoiceCloneForm.tsx | 117 ++++++----- .../src/components/tts/VoiceDesignForm.tsx | 123 ++++++----- .../src/components/ui/dropdown-menu.tsx | 198 ++++++++++++++++++ .../src/components/users/UserDialog.tsx | 37 ++-- .../src/components/users/UserTable.tsx | 49 +++-- .../src/contexts/AuthContext.tsx | 8 +- .../src/contexts/UserPreferencesContext.tsx | 12 ++ .../src/locales/en-US/auth.json | 25 +++ .../src/locales/en-US/common.json | 40 ++++ .../src/locales/en-US/constants.json | 152 ++++++++++++++ .../src/locales/en-US/errors.json | 42 ++++ qwen3-tts-frontend/src/locales/en-US/index.ts | 25 +++ qwen3-tts-frontend/src/locales/en-US/job.json | 55 +++++ qwen3-tts-frontend/src/locales/en-US/nav.json | 12 ++ .../src/locales/en-US/onboarding.json | 25 +++ .../src/locales/en-US/settings.json | 61 ++++++ qwen3-tts-frontend/src/locales/en-US/tts.json | 80 +++++++ .../src/locales/en-US/user.json | 59 ++++++ .../src/locales/en-US/voice.json | 59 ++++++ qwen3-tts-frontend/src/locales/index.ts | 34 +++ .../src/locales/ja-JP/auth.json | 25 +++ .../src/locales/ja-JP/common.json | 40 ++++ .../src/locales/ja-JP/constants.json | 152 ++++++++++++++ .../src/locales/ja-JP/errors.json | 42 ++++ qwen3-tts-frontend/src/locales/ja-JP/index.ts | 25 +++ qwen3-tts-frontend/src/locales/ja-JP/job.json | 55 +++++ qwen3-tts-frontend/src/locales/ja-JP/nav.json | 12 ++ .../src/locales/ja-JP/onboarding.json | 25 +++ .../src/locales/ja-JP/settings.json | 61 ++++++ qwen3-tts-frontend/src/locales/ja-JP/tts.json | 80 +++++++ .../src/locales/ja-JP/user.json | 59 ++++++ .../src/locales/ja-JP/voice.json | 59 ++++++ .../src/locales/ko-KR/auth.json | 25 +++ .../src/locales/ko-KR/common.json | 40 ++++ .../src/locales/ko-KR/constants.json | 152 ++++++++++++++ .../src/locales/ko-KR/errors.json | 42 ++++ qwen3-tts-frontend/src/locales/ko-KR/index.ts | 25 +++ qwen3-tts-frontend/src/locales/ko-KR/job.json | 55 +++++ qwen3-tts-frontend/src/locales/ko-KR/nav.json | 12 ++ .../src/locales/ko-KR/onboarding.json | 25 +++ .../src/locales/ko-KR/settings.json | 61 ++++++ qwen3-tts-frontend/src/locales/ko-KR/tts.json | 80 +++++++ .../src/locales/ko-KR/user.json | 59 ++++++ .../src/locales/ko-KR/voice.json | 59 ++++++ .../src/locales/zh-CN/auth.json | 25 +++ .../src/locales/zh-CN/common.json | 40 ++++ .../src/locales/zh-CN/constants.json | 152 ++++++++++++++ .../src/locales/zh-CN/errors.json | 42 ++++ qwen3-tts-frontend/src/locales/zh-CN/index.ts | 25 +++ qwen3-tts-frontend/src/locales/zh-CN/job.json | 55 +++++ qwen3-tts-frontend/src/locales/zh-CN/nav.json | 12 ++ .../src/locales/zh-CN/onboarding.json | 25 +++ .../src/locales/zh-CN/settings.json | 61 ++++++ qwen3-tts-frontend/src/locales/zh-CN/tts.json | 80 +++++++ .../src/locales/zh-CN/user.json | 59 ++++++ .../src/locales/zh-CN/voice.json | 59 ++++++ .../src/locales/zh-TW/auth.json | 25 +++ .../src/locales/zh-TW/common.json | 40 ++++ .../src/locales/zh-TW/constants.json | 152 ++++++++++++++ .../src/locales/zh-TW/errors.json | 42 ++++ qwen3-tts-frontend/src/locales/zh-TW/index.ts | 25 +++ qwen3-tts-frontend/src/locales/zh-TW/job.json | 55 +++++ qwen3-tts-frontend/src/locales/zh-TW/nav.json | 12 ++ .../src/locales/zh-TW/onboarding.json | 25 +++ .../src/locales/zh-TW/settings.json | 61 ++++++ qwen3-tts-frontend/src/locales/zh-TW/tts.json | 80 +++++++ .../src/locales/zh-TW/user.json | 59 ++++++ .../src/locales/zh-TW/voice.json | 59 ++++++ qwen3-tts-frontend/src/main.tsx | 1 + qwen3-tts-frontend/src/pages/Home.tsx | 8 +- qwen3-tts-frontend/src/pages/Login.tsx | 37 ++-- qwen3-tts-frontend/src/pages/Settings.tsx | 81 +++---- .../src/pages/UserManagement.tsx | 18 +- qwen3-tts-frontend/src/types/auth.ts | 1 + 87 files changed, 4290 insertions(+), 358 deletions(-) create mode 100644 qwen3-tts-frontend/@/components/ui/dropdown-menu.tsx create mode 100644 qwen3-tts-frontend/src/components/ui/dropdown-menu.tsx create mode 100644 qwen3-tts-frontend/src/locales/en-US/auth.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/common.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/constants.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/errors.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/index.ts create mode 100644 qwen3-tts-frontend/src/locales/en-US/job.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/nav.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/onboarding.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/settings.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/tts.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/user.json create mode 100644 qwen3-tts-frontend/src/locales/en-US/voice.json create mode 100644 qwen3-tts-frontend/src/locales/index.ts create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/auth.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/common.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/constants.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/errors.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/index.ts create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/job.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/nav.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/onboarding.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/settings.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/tts.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/user.json create mode 100644 qwen3-tts-frontend/src/locales/ja-JP/voice.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/auth.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/common.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/constants.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/errors.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/index.ts create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/job.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/nav.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/onboarding.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/settings.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/tts.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/user.json create mode 100644 qwen3-tts-frontend/src/locales/ko-KR/voice.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/auth.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/common.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/constants.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/errors.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/index.ts create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/job.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/nav.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/onboarding.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/settings.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/tts.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/user.json create mode 100644 qwen3-tts-frontend/src/locales/zh-CN/voice.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/auth.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/common.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/constants.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/errors.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/index.ts create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/job.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/nav.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/onboarding.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/settings.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/tts.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/user.json create mode 100644 qwen3-tts-frontend/src/locales/zh-TW/voice.json diff --git a/qwen3-tts-frontend/@/components/ui/dropdown-menu.tsx b/qwen3-tts-frontend/@/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..fc790a8 --- /dev/null +++ b/qwen3-tts-frontend/@/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/qwen3-tts-frontend/package-lock.json b/qwen3-tts-frontend/package-lock.json index 9feec01..1d7ae49 100644 --- a/qwen3-tts-frontend/package-lock.json +++ b/qwen3-tts-frontend/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", @@ -27,12 +28,15 @@ "axios": "^1.13.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^25.8.3", + "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.2", "react-dom": "^19.2", "react-h5-audio-player": "^3.10.1", "react-hook-form": "^7.71.1", + "react-i18next": "^16.5.4", "react-router-dom": "^7.13.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -1481,6 +1485,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1585,6 +1618,64 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -4288,6 +4379,55 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.3.tgz", + "integrity": "sha512-IC/pp2vkczdu1sBheq1eC92bLavN6fM5jH61c7Xa23PGio5ePEd+EP+re1IkO7KEM9eyeJHUxvIRxsaYTlsSyQ==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5396,6 +5536,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5941,7 +6108,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6066,6 +6233,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6148,6 +6324,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/qwen3-tts-frontend/package.json b/qwen3-tts-frontend/package.json index 063eab7..f9179e8 100644 --- a/qwen3-tts-frontend/package.json +++ b/qwen3-tts-frontend/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", @@ -29,12 +30,15 @@ "axios": "^1.13.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^25.8.3", + "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.2", "react-dom": "^19.2", "react-h5-audio-player": "^3.10.1", "react-hook-form": "^7.71.1", + "react-i18next": "^16.5.4", "react-router-dom": "^7.13.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/qwen3-tts-frontend/src/components/AudioPlayer.tsx b/qwen3-tts-frontend/src/components/AudioPlayer.tsx index ad630da..805d153 100644 --- a/qwen3-tts-frontend/src/components/AudioPlayer.tsx +++ b/qwen3-tts-frontend/src/components/AudioPlayer.tsx @@ -1,4 +1,5 @@ import { useRef, useState, useEffect, useCallback, memo } from 'react' +import { useTranslation } from 'react-i18next' import AudioPlayerLib from 'react-h5-audio-player' import 'react-h5-audio-player/lib/styles.css' import { Button } from '@/components/ui/button' @@ -16,6 +17,7 @@ const isMobileDevice = () => { } const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => { + const { t } = useTranslation('common') const [blobUrl, setBlobUrl] = useState('') const [isLoading, setIsLoading] = useState(false) const [loadError, setLoadError] = useState(null) @@ -60,7 +62,7 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => { } catch (error) { console.error("Failed to load audio:", error) if (active) { - setLoadError('Failed to load audio') + setLoadError(t('failedToLoadAudio')) } } finally { if (active) { @@ -92,7 +94,7 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => { if (isLoading) { return (
- Loading... + {t('loadingAudio')}
) } diff --git a/qwen3-tts-frontend/src/components/AudioRecorder.tsx b/qwen3-tts-frontend/src/components/AudioRecorder.tsx index d1f82de..2719d52 100644 --- a/qwen3-tts-frontend/src/components/AudioRecorder.tsx +++ b/qwen3-tts-frontend/src/components/AudioRecorder.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Mic, Trash2, RotateCcw, FileAudio } from 'lucide-react' import { toast } from 'sonner' @@ -10,6 +11,7 @@ interface AudioRecorderProps { } export function AudioRecorder({ onChange }: AudioRecorderProps) { + const { t } = useTranslation('voice') const { isRecording, recordingDuration, @@ -54,7 +56,7 @@ export function AudioRecorder({ onChange }: AudioRecorderProps) { setAudioInfo({ duration: result.duration, size: file.size }) setValidationError(null) } else { - setValidationError(result.error || '录音验证失败') + setValidationError(result.error || t('recordingValidationFailed')) clearRecording() onChange(null) } @@ -98,7 +100,7 @@ export function AudioRecorder({ onChange }: AudioRecorderProps) { if (!isSupported) { return (
- 您的浏览器不支持录音功能 + {t('browserNotSupported')}
) } @@ -109,9 +111,9 @@ export function AudioRecorder({ onChange }: AudioRecorderProps) {
-

录制完成

+

{t('recordingComplete')}

- {(audioInfo.size / 1024 / 1024).toFixed(2)} MB · {audioInfo.duration.toFixed(1)} 秒 + {(audioInfo.size / 1024 / 1024).toFixed(2)} MB · {audioInfo.duration.toFixed(1)} {t('seconds')}

diff --git a/qwen3-tts-frontend/src/components/FileUploader.tsx b/qwen3-tts-frontend/src/components/FileUploader.tsx index a84ef84..8f6920a 100644 --- a/qwen3-tts-frontend/src/components/FileUploader.tsx +++ b/qwen3-tts-frontend/src/components/FileUploader.tsx @@ -1,4 +1,5 @@ import { useRef, useState, type ChangeEvent } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Upload, X, FileAudio } from 'lucide-react' import { toast } from 'sonner' @@ -16,6 +17,7 @@ interface FileUploaderProps { } export function FileUploader({ value, onChange, error }: FileUploaderProps) { + const { t } = useTranslation('voice') const inputRef = useRef(null) const { validateAudioFile } = useAudioValidation() const [isValidating, setIsValidating] = useState(false) @@ -33,7 +35,7 @@ export function FileUploader({ value, onChange, error }: FileUploaderProps) { onChange(file) setAudioInfo({ duration: result.duration, size: file.size }) } else { - toast.error(result.error || '文件验证失败') + toast.error(result.error || t('validationFailed')) e.target.value = '' } } @@ -56,7 +58,7 @@ export function FileUploader({ value, onChange, error }: FileUploaderProps) { disabled={isValidating} > - {isValidating ? '验证中...' : '选择音频文件'} + {isValidating ? t('validating') : t('selectAudioFile')} ) : (
@@ -65,7 +67,7 @@ export function FileUploader({ value, onChange, error }: FileUploaderProps) {

{value.name}

{audioInfo && (

- {(audioInfo.size / 1024 / 1024).toFixed(2)} MB · {audioInfo.duration.toFixed(1)} 秒 + {(audioInfo.size / 1024 / 1024).toFixed(2)} MB · {audioInfo.duration.toFixed(1)} {t('seconds')}

)}
diff --git a/qwen3-tts-frontend/src/components/HistoryItem.tsx b/qwen3-tts-frontend/src/components/HistoryItem.tsx index cfefb82..188ec32 100644 --- a/qwen3-tts-frontend/src/components/HistoryItem.tsx +++ b/qwen3-tts-frontend/src/components/HistoryItem.tsx @@ -1,4 +1,5 @@ import { memo, useState } from 'react' +import { useTranslation } from 'react-i18next' import type { Job } from '@/types/job' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -28,17 +29,19 @@ const jobTypeBadgeVariant = { voice_clone: 'outline' as const, } -const jobTypeLabel = { - custom_voice: '自定义音色', - voice_design: '音色设计', - voice_clone: '声音克隆', -} - const HistoryItem = memo(({ job, onDelete }: HistoryItemProps) => { + const { t } = useTranslation('job') + const { t: tCommon } = useTranslation('common') const [detailDialogOpen, setDetailDialogOpen] = useState(false) + const jobTypeLabel = { + custom_voice: t('typeCustomVoice'), + voice_design: t('typeVoiceDesign'), + voice_clone: t('typeVoiceClone'), + } + const getLanguageDisplay = (lang: string | undefined) => { - if (!lang || lang === 'Auto') return '自动检测' + if (!lang || lang === 'Auto') return t('autoDetect') return lang } @@ -68,31 +71,31 @@ const HistoryItem = memo(({ job, onDelete }: HistoryItemProps) => {
{job.parameters?.text && (
- 文本内容: + {t('synthesisText')}: {job.parameters.text}
)}
- 语言: {getLanguageDisplay(job.parameters?.language)} + {t('language')}{getLanguageDisplay(job.parameters?.language)}
{job.type === 'custom_voice' && job.parameters?.speaker && (
- 发音人: {job.parameters.speaker} + {t('speaker')}{job.parameters.speaker}
)} {job.type === 'voice_design' && job.parameters?.instruct && (
- 音色描述: + {t('voiceDescription')}: {job.parameters.instruct}
)} {job.type === 'voice_clone' && job.parameters?.ref_text && (
- 参考文本: + {t('referenceText')}: {job.parameters.ref_text}
)} @@ -101,14 +104,14 @@ const HistoryItem = memo(({ job, onDelete }: HistoryItemProps) => { {job.status === 'processing' && (
- 处理中... + {t('statusProcessing')}
)} {job.status === 'pending' && (
- 等待处理... + {t('statusPending')}
)} @@ -132,18 +135,18 @@ const HistoryItem = memo(({ job, onDelete }: HistoryItemProps) => { - 确认删除 + {t('deleteJob')} - 确定要删除这条历史记录吗?此操作无法撤销。 + {t('deleteJobConfirm')} - 取消 + {tCommon('cancel')} onDelete(job.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - 删除 + {tCommon('delete')} diff --git a/qwen3-tts-frontend/src/components/HistorySidebar.tsx b/qwen3-tts-frontend/src/components/HistorySidebar.tsx index 229d613..b7697ea 100644 --- a/qwen3-tts-frontend/src/components/HistorySidebar.tsx +++ b/qwen3-tts-frontend/src/components/HistorySidebar.tsx @@ -1,4 +1,5 @@ import { useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { useHistoryContext } from '@/contexts/HistoryContext' import { HistoryItem } from '@/components/HistoryItem' import { ScrollArea } from '@/components/ui/scroll-area' @@ -12,6 +13,7 @@ interface HistorySidebarProps { } function HistorySidebarContent() { + const { t } = useTranslation('job') const { jobs, loading, loadingMore, hasMore, loadMore, deleteJob, error, retry } = useHistoryContext() const observerTarget = useRef(null) @@ -35,8 +37,8 @@ function HistorySidebarContent() { return (
-

历史记录

-

共 {jobs.length} 条记录

+

{t('historyTitle')}

+

{t('historyCount', { count: jobs.length })}

@@ -50,15 +52,15 @@ function HistorySidebarContent() {

{error}

) : jobs.length === 0 ? (
-

暂无历史记录

+

{t('noHistory')}

- 生成语音后,记录将会显示在这里 + {t('historyDescription')}

) : ( diff --git a/qwen3-tts-frontend/src/components/JobDetailDialog.tsx b/qwen3-tts-frontend/src/components/JobDetailDialog.tsx index 6d52aae..cba010b 100644 --- a/qwen3-tts-frontend/src/components/JobDetailDialog.tsx +++ b/qwen3-tts-frontend/src/components/JobDetailDialog.tsx @@ -1,4 +1,5 @@ import { memo } from 'react' +import { useTranslation } from 'react-i18next' import type { Job } from '@/types/job' import { Dialog, @@ -31,14 +32,8 @@ const jobTypeBadgeVariant = { voice_clone: 'outline' as const, } -const jobTypeLabel = { - custom_voice: '自定义音色', - voice_design: '音色设计', - voice_clone: '声音克隆', -} - -const formatTimestamp = (timestamp: string) => { - return new Date(timestamp).toLocaleString('zh-CN', { +const formatTimestamp = (timestamp: string, locale: string) => { + return new Date(timestamp).toLocaleString(locale, { year: 'numeric', month: '2-digit', day: '2-digit', @@ -47,18 +42,26 @@ const formatTimestamp = (timestamp: string) => { }) } -const getLanguageDisplay = (lang: string | undefined) => { - if (!lang || lang === 'Auto') return '自动检测' - return lang -} - -const formatBooleanDisplay = (value: boolean | undefined) => { - return value ? '是' : '否' -} - const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) => { + const { t, i18n } = useTranslation(['job', 'common']) + if (!job) return null + const jobTypeLabel = { + custom_voice: t('job:typeCustomVoice'), + voice_design: t('job:typeVoiceDesign'), + voice_clone: t('job:typeVoiceClone'), + } + + const getLanguageDisplay = (lang: string | undefined) => { + if (!lang || lang === 'Auto') return t('job:autoDetect') + return lang + } + + const formatBooleanDisplay = (value: boolean | undefined) => { + return value ? t('common:yes') : t('common:no') + } + const canPlay = job.status === 'completed' const audioUrl = canPlay ? jobApi.getAudioUrl(job.id, job.audio_url) : '' @@ -74,35 +77,35 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) #{job.id} - {formatTimestamp(job.created_at)} + {formatTimestamp(job.created_at, i18n.language)}
- 查看任务的详细参数和生成结果 + {t('job:detailsDescription')}
-

基本信息

+

{t('job:basicInfo')}

{job.type === 'custom_voice' && job.parameters?.speaker && (
- 发音人: + {t('job:speaker')} {job.parameters.speaker}
)}
- 语言: + {t('job:language')} {getLanguageDisplay(job.parameters?.language)}
{job.type === 'voice_clone' && ( <>
- 快速模式: + {t('job:fastMode')} {formatBooleanDisplay(job.parameters?.x_vector_only_mode)}
- 使用缓存: + {t('job:useCache')} {formatBooleanDisplay(job.parameters?.use_cache)}
@@ -113,9 +116,9 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps)
-

合成文本

+

{t('job:synthesisText')}

- {job.parameters?.text || 未设置} + {job.parameters?.text || {t('job:notSet')}}
@@ -123,7 +126,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) <>
-

音色描述

+

{t('job:voiceDescription')}

{job.parameters.instruct}
@@ -135,7 +138,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) <>
-

情绪指导

+

{t('job:emotionGuidance')}

{job.parameters.instruct}
@@ -147,9 +150,9 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) <>
-

参考文本

+

{t('job:referenceText')}

- {job.parameters?.ref_text || 未提供} + {job.parameters?.ref_text || {t('job:notProvided')}}
@@ -159,38 +162,38 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) - 高级参数 + {t('job:advancedParameters')}
{job.parameters?.max_new_tokens !== undefined && (
- 最大生成长度: + {t('job:maxNewTokens')} {job.parameters.max_new_tokens}
)} {job.parameters?.temperature !== undefined && (
- 温度: + {t('job:temperature')} {job.parameters.temperature}
)} {job.parameters?.top_k !== undefined && (
- Top K: + {t('job:topK')} {job.parameters.top_k}
)} {job.parameters?.top_p !== undefined && (
- Top P: + {t('job:topP')} {job.parameters.top_p}
)} {job.parameters?.repetition_penalty !== undefined && (
- 重复惩罚: + {t('job:repetitionPenalty')} {job.parameters.repetition_penalty}
)} @@ -204,7 +207,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps)
-

错误信息

+

{t('job:errorMessage')}

{job.error_message}

@@ -215,7 +218,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps) <>
-

音频播放

+

{t('job:audioPlayback')}

diff --git a/qwen3-tts-frontend/src/components/LoadingState.tsx b/qwen3-tts-frontend/src/components/LoadingState.tsx index f1e0de3..dad3ca4 100644 --- a/qwen3-tts-frontend/src/components/LoadingState.tsx +++ b/qwen3-tts-frontend/src/components/LoadingState.tsx @@ -1,19 +1,22 @@ import { memo } from 'react' +import { useTranslation } from 'react-i18next' interface LoadingStateProps { elapsedTime: number } const LoadingState = memo(({ elapsedTime }: LoadingStateProps) => { + const { t } = useTranslation('common') + const displayText = elapsedTime > 60 - ? '生成用时较长,请耐心等待...' - : '正在生成音频,请稍候...' + ? t('generationTakingLong') + : t('generatingAudio') return (

{displayText}

- 已等待 {elapsedTime} 秒 + {t('waitedSeconds', { seconds: elapsedTime })}

) diff --git a/qwen3-tts-frontend/src/components/Navbar.tsx b/qwen3-tts-frontend/src/components/Navbar.tsx index ac691a8..b655605 100644 --- a/qwen3-tts-frontend/src/components/Navbar.tsx +++ b/qwen3-tts-frontend/src/components/Navbar.tsx @@ -1,8 +1,16 @@ -import { Menu, LogOut, Users, Settings, AudioLines } from 'lucide-react' +import { Menu, LogOut, Users, Settings, AudioLines, Globe } from 'lucide-react' import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { ThemeToggle } from '@/components/ThemeToggle' import { useAuth } from '@/contexts/AuthContext' +import { useUserPreferences } from '@/contexts/UserPreferencesContext' interface NavbarProps { onToggleSidebar?: () => void @@ -10,6 +18,8 @@ interface NavbarProps { export function Navbar({ onToggleSidebar }: NavbarProps) { const { logout, user } = useAuth() + const { changeLanguage } = useUserPreferences() + const { t, i18n } = useTranslation(['nav', 'constants']) return (