From f0c9dbfd36d518cde1b3da46a23312d5fbeb1ce0 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 17:50:57 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E6=96=B0=E5=A2=9E=20AI=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=AF=BC=E5=87=BA=20JSON?= =?UTF-8?q?=20=E5=A4=8D=E5=88=B6=E5=88=B0=E5=89=AA=E8=B4=B4=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.default | 2 + .gitignore | 3 + package.json | 2 + pnpm-lock.yaml | 17 +++ src/components/biz/ai-translate-modal.tsx | 111 +++++++++++++++++++ src/components/biz/export-language-modal.tsx | 12 ++ src/hooks/use-clipboard.ts | 54 +++++++++ src/lib/ai.ts | 85 ++++++++++++++ src/main.tsx | 4 +- src/pages/editor.tsx | 69 ++++++++++-- 10 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 .env.default create mode 100644 src/components/biz/ai-translate-modal.tsx create mode 100644 src/hooks/use-clipboard.ts create mode 100644 src/lib/ai.ts diff --git a/.env.default b/.env.default new file mode 100644 index 0000000..2cb636c --- /dev/null +++ b/.env.default @@ -0,0 +1,2 @@ +VITE_OPENAI_BASE_URL="https://openrouter.ai/api/v1" +VITE_OPENAI_API_KEY="sk-or-v1-xxxxx" diff --git a/.gitignore b/.gitignore index a547bf3..b58ff06 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +.env +.env.production diff --git a/package.json b/package.json index c9d0d23..d2f1a3a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "prod": "tsc -b && vite build --base=/translate-it/", "lint": "eslint .", "preview": "vite preview" }, @@ -17,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.552.0", + "openai": "^6.7.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d11f05..8487d66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: lucide-react: specifier: ^0.552.0 version: 0.552.0(react@19.2.0) + openai: + specifier: ^6.7.0 + version: 6.7.0 react: specifier: ^19.1.1 version: 19.2.0 @@ -1450,6 +1453,18 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + openai@6.7.0: + resolution: {integrity: sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2995,6 +3010,8 @@ snapshots: node-releases@2.0.27: {} + openai@6.7.0: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/components/biz/ai-translate-modal.tsx b/src/components/biz/ai-translate-modal.tsx new file mode 100644 index 0000000..bf2b389 --- /dev/null +++ b/src/components/biz/ai-translate-modal.tsx @@ -0,0 +1,111 @@ +import { memo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { requestTranslations } from "@/lib/ai"; + +type Props = { + open: boolean; + onOpenChange: (next: boolean) => void; + languages: string[]; + path: string; + onConfirm: (result: Record) => Promise | void; +}; + +function AiTranslateModalImpl({ open, onOpenChange, languages, path, onConfirm }: Props) { + const [text, setText] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const payload = text.trim(); + if (!payload) { + setError("请输入待翻译文本"); + return; + } + if (languages.length === 0) { + setError("当前项目暂无目标语言"); + return; + } + setLoading(true); + setError(null); + try { + const result = await requestTranslations({ text: payload, languages }); + // 二次校验 keys 完整性 + for (const l of languages) { + if (!Object.prototype.hasOwnProperty.call(result, l)) { + throw new Error("AI 返回的 key 不完整"); + } + } + await onConfirm(result); + setText(""); + onOpenChange(false); + } catch (e) { + setError((e as Error)?.message ?? "AI 翻译失败"); + } finally { + setLoading(false); + } + } + + return ( + { + if (!loading) { + onOpenChange(v); + if (!v) setError(null); + } + }} + > + + + {`AI 翻译 — ${path || "条目"}`} + +
+
+ + setText(e.target.value)} + placeholder="请输入原文本" + aria-label="待翻译文本" + /> +
+
+ 目标语言:{languages.join(", ") || "无"} +
+ {error && ( +
{error}
+ )} + + + + +
+
+
+ ); +} + +export const AiTranslateModal = memo(AiTranslateModalImpl); + + diff --git a/src/components/biz/export-language-modal.tsx b/src/components/biz/export-language-modal.tsx index efdf200..0f302a6 100644 --- a/src/components/biz/export-language-modal.tsx +++ b/src/components/biz/export-language-modal.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useMemo, useState } from "react"; +import { Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -8,6 +9,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { unflattenValues } from "@/lib/i18n-structure"; +import { useClipboard } from "@/hooks/use-clipboard"; type Props = { open: boolean; @@ -25,6 +27,7 @@ function ExportLanguageModalImpl({ orderedPaths, }: Props) { const [selected, setSelected] = useState(""); + const { copied, copy } = useClipboard({ resetAfterMs: 1500 }); useEffect(() => { if (open) { @@ -102,6 +105,12 @@ function ExportLanguageModalImpl({ URL.revokeObjectURL(url); } + const handleCopy = () => { + if (!jsonText) return; + + copy(jsonText); + } + return ( @@ -141,6 +150,9 @@ function ExportLanguageModalImpl({ + diff --git a/src/hooks/use-clipboard.ts b/src/hooks/use-clipboard.ts new file mode 100644 index 0000000..3ebbe53 --- /dev/null +++ b/src/hooks/use-clipboard.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type UseClipboardOptions = { + resetAfterMs?: number; +}; + +export function useClipboard(options?: UseClipboardOptions) { + const resetAfterMs = options?.resetAfterMs ?? 1500; + const [copied, setCopied] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + return () => { + if (timerRef.current != null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, []); + + const copy = useCallback(async (text: string): Promise => { + try { + if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + textarea.setAttribute("readonly", ""); + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + if (!ok) return false; + } + setCopied(true); + if (timerRef.current != null) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + setCopied(false); + timerRef.current = null; + }, resetAfterMs); + return true; + } catch { + return false; + } + }, [resetAfterMs]); + + return { copied, copy } as const; +} + + diff --git a/src/lib/ai.ts b/src/lib/ai.ts new file mode 100644 index 0000000..8965394 --- /dev/null +++ b/src/lib/ai.ts @@ -0,0 +1,85 @@ +import OpenAI from "openai"; + +export type AiTranslateParams = { + text: string; + languages: string[]; + model?: string; +}; + +function getClient() { + const apiKey = import.meta.env?.VITE_OPENAI_API_KEY as string | undefined; + const baseURL = import.meta.env?.VITE_OPENAI_BASE_URL as string | undefined; + + if (!apiKey) { + throw new Error("缺少环境变量 VITE_OPENAI_API_KEY"); + } + return new OpenAI({ apiKey, dangerouslyAllowBrowser: true, baseURL }); +} + +export async function requestTranslations({ + text, + languages, + model, +}: AiTranslateParams): Promise> { + const client = getClient(); + const mdl = + model || + (import.meta.env?.VITE_OPENAI_MODEL as string) || + // "gpt-4o-mini"; + "openai/gpt-5"; + + const targetList = JSON.stringify(languages); + const instruction = [ + "你是一个专业的翻译助手。", + `请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`, + "严格要求:", + "- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。", + "- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。", + "- 如果某一语言确实无法翻译,则将该键的值设置为原文。", + '示例:{"en":"Cat","zh-Hans":"猫"}', + ].join("\n"); + + const prompt = [instruction, "\n用户文本:\n" + text].join("\n\n"); + + const response = await client.responses.create({ + model: mdl, + input: prompt, + response_format: { type: "json_object" }, + } as any); + + const raw = + (response as any).output_text ?? + (response as any).content?.[0]?.text ?? + (response as any).choices?.[0]?.message?.content ?? + ""; + + if (typeof raw !== "string" || raw.trim() === "") { + throw new Error("AI 返回为空"); + } + + let obj: unknown; + try { + obj = JSON.parse(raw); + } catch { + throw new Error("AI 返回的 JSON 解析失败"); + } + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + throw new Error("AI 返回的 JSON 结构非法"); + } + + const result = obj as Record; + for (const lang of languages) { + if (!Object.prototype.hasOwnProperty.call(result, lang)) { + throw new Error("AI 返回的 key 不完整"); + } + const v = result[lang]; + if (typeof v !== "string") { + throw new Error("AI 返回的某些值类型不是字符串"); + } + } + + return languages.reduce>((acc, l) => { + acc[l] = String((result as any)[l] ?? ""); + return acc; + }, {}); +} diff --git a/src/main.tsx b/src/main.tsx index 8587fa0..af42b23 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { createBrowserRouter } from "react-router"; +import { createHashRouter } from "react-router"; import { RouterProvider } from "react-router/dom"; import "./index.css"; import Home from "./pages/home.tsx"; import Editor from "./pages/editor.tsx"; -const router = createBrowserRouter([ +const router = createHashRouter([ { path: "/", element: , diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index cf775c9..44b9a56 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -10,16 +10,18 @@ import { type Project, type ProjectStructure, getLanguageTranslations, + upsertLanguageTranslations, upsertStructure, deleteEntryFromAllLanguages, renameEntryInAllLanguages, } from "@/lib/db"; -import { ArrowLeft } from "lucide-react"; +import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react"; import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit"; import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure"; import { ImportLanguageModal } from "@/components/biz/import-language-modal"; import { ExportLanguageModal } from "@/components/biz/export-language-modal"; import { EntryNameModal } from "@/components/biz/entry-name-modal"; +import { AiTranslateModal } from "@/components/biz/ai-translate-modal"; import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso"; import { DropdownMenu, @@ -45,6 +47,7 @@ export default function Editor() { const [query, setQuery] = useState(""); const [caseSensitive, setCaseSensitive] = useState(false); const [fullMatch, setFullMatch] = useState(false); + const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); function highlightRow(index: number) { const tryFindAndAnimate = (attempt = 0) => { @@ -71,7 +74,10 @@ export default function Editor() { requestAnimationFrame(() => tryFindAndAnimate(0)); } - function scrollToQuery() { + function scrollToQuery(ev: React.FormEvent) { + ev.preventDefault(); + ev.stopPropagation(); + if (!query) return; const idx = entries.findIndex((e) => { const hay = caseSensitive ? e.path : e.path.toLowerCase(); @@ -176,17 +182,32 @@ export default function Editor() { - + setAddModal({ open: true, path: entry.path, position: "below" })}> + 在下面新增 setAddModal({ open: true, path: entry.path, position: "above" })}> + 在上面新增 + setAiModal({ open: true, path: entry.path })}> + + AI 翻译 + + + setRenameModal({ open: true, path: entry.path })}> + + 重命名 + { if (!projectId || !structure) return; if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return; @@ -209,11 +230,9 @@ export default function Editor() { } }} > + 删除 - setRenameModal({ open: true, path: entry.path })}> - 重命名 - @@ -282,13 +301,14 @@ export default function Editor() { ) : (
-
+
setQuery(e.target.value)} /> - -
+ +
e.path)} /> + setAiModal((cur) => (cur ? { ...cur, open: v } : cur))} + languages={languages} + path={aiModal?.path ?? ""} + onConfirm={async (translations) => { + if (!projectId || !aiModal) return; + const targetPath = aiModal.path; + const updates: Record> = {}; + try { + await Promise.all( + languages.map(async (lang) => { + const prev = valuesByLang[lang] ?? {}; + const next = { ...prev, [targetPath]: translations[lang] }; + updates[lang] = next; + await upsertLanguageTranslations(projectId, lang, next); + }) + ); + setValuesByLang((old) => ({ ...old, ...updates })); + } catch (e) { + const msg = (e as Error)?.message ?? "保存翻译失败"; + setPageError(msg); + throw e; + } + }} + /> + setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}