From 61dee0b1270b171dcc7c184d4fe757ca61dbff72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=87=E8=B6=A3=E4=BF=9D=E7=BD=97?= Date: Tue, 4 Nov 2025 21:42:58 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E5=BC=BA=E5=88=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=9B=BE=EF=BC=8C=E6=8E=A7=E5=88=B6=E5=8F=B0?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E7=BB=93=E6=9E=84=E5=9B=BE=E4=B8=8D=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E7=9A=84=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- package.json | 3 + pnpm-lock.yaml | 75 ++++++++++++++++++++ src/components/biz/export-language-modal.tsx | 8 ++- src/components/biz/import-language-modal.tsx | 36 +++++++++- src/components/ui/checkbox.tsx | 30 ++++++++ src/components/ui/sonner.tsx | 38 ++++++++++ src/main.tsx | 4 +- src/pages/editor.tsx | 34 ++++++--- 9 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/sonner.tsx diff --git a/index.html b/index.html index 30f36ef..f046e0b 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - my + Translate It
diff --git a/package.json b/package.json index d2f1a3a..1de5c7d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", @@ -18,11 +19,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.552.0", + "next-themes": "^0.4.6", "openai": "^6.7.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.5", "react-virtuoso": "^4.7.11", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.16" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8487d66..4aa6584 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -29,6 +32,9 @@ importers: lucide-react: specifier: ^0.552.0 version: 0.552.0(react@19.2.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) openai: specifier: ^6.7.0 version: 6.7.0 @@ -44,6 +50,9 @@ importers: react-virtuoso: specifier: ^4.7.11 version: 4.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -445,6 +454,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + 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 + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -687,6 +709,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -1450,6 +1481,12 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -1613,6 +1650,12 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2067,6 +2110,22 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -2302,6 +2361,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.0)': dependencies: '@radix-ui/rect': 1.1.1 @@ -3008,6 +3073,11 @@ snapshots: natural-compare@1.4.0: {} + next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + node-releases@2.0.27: {} openai@6.7.0: {} @@ -3154,6 +3224,11 @@ snapshots: shebang-regex@3.0.0: {} + sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + source-map-js@1.2.1: {} strip-json-comments@3.1.1: {} diff --git a/src/components/biz/export-language-modal.tsx b/src/components/biz/export-language-modal.tsx index 0f302a6..10128e1 100644 --- a/src/components/biz/export-language-modal.tsx +++ b/src/components/biz/export-language-modal.tsx @@ -73,7 +73,11 @@ function ExportLanguageModalImpl({ } // 追加结构外的剩余键,避免丢数据 for (const p of Object.keys(flat)) { - if (!added.has(p)) setByPath(p, flat[p]!); + if (!added.has(p)) { + console.warn(`在结构图中未找到路径: ${p}`); + + setByPath(p, flat[p]!); + } } try { return JSON.stringify(root, null, 2); @@ -115,7 +119,7 @@ function ExportLanguageModalImpl({ - 导出语言 JSON + 导出 JSON
diff --git a/src/components/biz/import-language-modal.tsx b/src/components/biz/import-language-modal.tsx index b456b78..5a056db 100644 --- a/src/components/biz/import-language-modal.tsx +++ b/src/components/biz/import-language-modal.tsx @@ -10,6 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Checkbox } from "../ui/checkbox"; type Props = { open: boolean; @@ -17,6 +18,7 @@ type Props = { projectId: string; hasStructure: boolean; onImported: () => void | Promise; + languages: string[]; }; function ImportLanguageModalImpl({ @@ -25,11 +27,13 @@ function ImportLanguageModalImpl({ projectId, hasStructure, onImported, + languages, }: Props) { const [lang, setLang] = useState(""); const [importing, setImporting] = useState(false); const [error, setError] = useState(null); const fileRef = useRef(null); + const [forceOverride, setForceOverride] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -57,14 +61,20 @@ function ImportLanguageModalImpl({ } catch { throw new Error("JSON 解析失败,请检查文件内容"); } - if (!hasStructure) { + + // 如果没有结构或勾选强制覆盖,则根据文件重建结构 + if (!hasStructure || forceOverride) { const root = buildStructureFromObject(json); await upsertStructure({ projectId, root }); + console.log("构建结构成功", root); } + + // 导入翻译(可能包含结构外的键,注意需要保留额外处理) const values = flattenValues(json); await upsertLanguageTranslations(projectId, language, values); setLang(""); if (fileRef.current) fileRef.current.value = ""; + setForceOverride(false); onOpenChange(false); await onImported(); } catch (e) { @@ -81,13 +91,14 @@ function ImportLanguageModalImpl({ if (!importing) { onOpenChange(v); if (!v) setError(null); + if (!v) setForceOverride(false); } }} > - {hasStructure ? "导入语言 JSON" : "导入翻译 JSON 并构建结构"} + {hasStructure ? "导入 JSON" : "导入 JSON 并构建结构"}
@@ -108,11 +119,31 @@ function ImportLanguageModalImpl({ setLang(e.target.value)} aria-label="语言代号" /> + + {languages.map((lang) => ( +
+ {hasStructure && ( +
+ +
+ 勾选后将使用上传文件的结构覆盖当前项目结构,此操作不可撤销。 +
+
+ )} {error && (
{error} @@ -125,6 +156,7 @@ function ImportLanguageModalImpl({ onClick={() => { onOpenChange(false); setError(null); + setForceOverride(false); }} disabled={importing} > diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..0e2a6cd --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..c9cd094 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,38 @@ +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/main.tsx b/src/main.tsx index af42b23..b3f80de 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { RouterProvider } from "react-router/dom"; import "./index.css"; import Home from "./pages/home.tsx"; import Editor from "./pages/editor.tsx"; +import { Toaster } from "@/components/ui/sonner"; const router = createHashRouter([ { @@ -19,6 +20,7 @@ const router = createHashRouter([ createRoot(document.getElementById("root")!).render( - , + + ); diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index 44b9a56..7beba1e 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -30,6 +30,8 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { toast } from "sonner"; export default function Editor() { const { id: projectId } = useParams(); @@ -49,6 +51,8 @@ export default function Editor() { const [fullMatch, setFullMatch] = useState(false); const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); + const { copy } = useClipboard(); + function highlightRow(index: number) { const tryFindAndAnimate = (attempt = 0) => { const root = scrollerRootRef.current as HTMLElement | null; @@ -138,18 +142,27 @@ export default function Editor() { const headerContent = useCallback(() => ( - 翻译条目名称 + 翻译条目名称 {languages.map((lang) => ( - {lang} + {lang} ))} - 操作 + 操作 ), [languages, colWidth]); const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { + const handleCopy = () => { + copy(entry.path); + toast.success("复制成功"); + }; + return ( <> - {entry.path} + + + {languages.map((lang) => { const isEditing = inline.isEditingCell(entry.path, lang); const isSaving = inline.isSavingCell(entry.path, lang); @@ -183,7 +196,6 @@ export default function Editor() { @@ -258,7 +270,7 @@ export default function Editor() { return (
-
+
) : ( - + )} {languages.length > 0 && ( @@ -300,7 +312,7 @@ export default function Editor() {
) : ( -
+
setQuery(e.target.value)} />