From 9766bc241103839ff75c03734374e4db36de16b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=87=E8=B6=A3=E4=BF=9D=E7=BD=97?= Date: Wed, 5 Nov 2025 02:05:58 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E5=A4=9A=E9=80=89=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=EF=BC=8C=E6=9C=80=E5=A4=9A=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=2050=20=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/biz/ai-translate-modal.tsx | 70 ++++++++----- src/components/ui/button.tsx | 2 + src/lib/ai.ts | 49 ++++++--- src/pages/editor.tsx | 118 +++++++++++++++++++++- 4 files changed, 192 insertions(+), 47 deletions(-) diff --git a/src/components/biz/ai-translate-modal.tsx b/src/components/biz/ai-translate-modal.tsx index 8dd2f06..b18a502 100644 --- a/src/components/biz/ai-translate-modal.tsx +++ b/src/components/biz/ai-translate-modal.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from "react"; +import { memo, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -14,21 +14,36 @@ type Props = { open: boolean; onOpenChange: (next: boolean) => void; languages: string[]; - path: string; + paths: string[]; prompt: string | undefined; - onConfirm: (result: Record) => Promise | void; + onConfirm: (result: Record>) => Promise | void; }; -function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onConfirm }: Props) { - const [text, setText] = useState(""); +function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, onConfirm }: Props) { + const [inputsByKey, setInputsByKey] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const MAX_ITEMS = 50; + const overLimit = paths.length > MAX_ITEMS; + + useEffect(() => { + if (open) { + const init: Record = {}; + for (const p of paths) init[p] = inputsByKey[p] ?? ""; + setInputsByKey(init); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, paths.join("|")]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - const payload = text.trim(); - if (!payload) { - setError("请输入待翻译文本"); + if (overLimit) { + setError(`一次最多支持 ${MAX_ITEMS} 条`); + return; + } + const items = paths.map((key) => ({ key, text: (inputsByKey[key] ?? "").trim() })); + if (items.some((it) => !it.text)) { + setError("请为所有条目输入原文"); return; } if (languages.length === 0) { @@ -38,15 +53,9 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC setLoading(true); setError(null); try { - const result = await requestTranslations({ text: payload, languages, prompt }); - // 二次校验 keys 完整性 - for (const l of languages) { - if (!Object.prototype.hasOwnProperty.call(result, l)) { - throw new Error("AI 返回的 key 不完整"); - } - } + const result = await requestTranslations({ items, languages, prompt }); await onConfirm(result); - setText(""); + setInputsByKey({}); onOpenChange(false); } catch (e) { setError((e as Error)?.message ?? "AI 翻译失败"); @@ -65,24 +74,29 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC } }} > - + - {`AI 翻译 — ${path || "条目"}`} + {`AI 翻译 — 已选 ${paths.length} 条`}
-
- - setText(e.target.value)} - placeholder="请输入原文本" - aria-label="待翻译文本" - /> +
+ {paths.map((p) => ( +
+
{p}
+ setInputsByKey((old) => ({ ...old, [p]: e.target.value }))} + placeholder="请输入原文本" + aria-label={`原文 ${p}`} + /> +
+ ))}
目标语言:{languages.join(", ") || "无"}
- {error && ( + {(error || overLimit) && (
{error}
)} @@ -97,7 +111,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC > 取消 - diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index d478c9c..f1bb600 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -14,6 +14,8 @@ const buttonVariants = cva( "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "outline-destructive": + "border border-destructive text-destructive hover:bg-destructive/10 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 5e6fcec..3735b49 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,7 +1,7 @@ import OpenAI from "openai"; export type AiTranslateParams = { - text: string; + items: { key: string; text: string }[]; languages: string[]; model?: string; prompt?: string; @@ -18,11 +18,11 @@ function getClient() { } export async function requestTranslations({ - text, + items, languages, model, prompt, -}: AiTranslateParams): Promise> { +}: AiTranslateParams): Promise>> { const client = getClient(); const mdl = model || @@ -31,19 +31,25 @@ export async function requestTranslations({ "openai/gpt-5"; const targetList = JSON.stringify(languages); + const itemsJson = JSON.stringify(items, null, 0); + const keysJson = JSON.stringify(items.map((it) => it.key)); const instruction = [ "你是一个专业的翻译助手。", - `请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`, + `请将多条原文同时翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`, "翻译偏好:", prompt, "严格要求:", "- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。", - "- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。", - "- 如果某一语言确实无法翻译,则将该键的值设置为原文。", - '示例:{"en":"Cat","zh-Hans":"猫"}', + "- JSON 的顶层键必须严格等于目标语言代码;其值必须是一个对象,键为条目 key,值为该 key 的翻译纯文本。", + "- 对所有提供的 key 都必须返回译文;若无法翻译,使用原文。", + '返回 JSON 示例:{"en":{"a.b":"Text"},"zh-Hans":{"a.b":"文本"}}', ].join("\n"); - const promptResult = [instruction, "\n用户文本:\n" + text].join("\n\n"); + const promptResult = [ + instruction, + "\n条目数组(含 key 与原文):\n" + itemsJson, + "\n仅对上述 keys 翻译并按 key 原样返回(keys 列表):\n" + keysJson, + ].join("\n\n"); const response = await client.responses.create({ model: mdl, @@ -74,16 +80,29 @@ export async function requestTranslations({ const result = obj as Record; for (const lang of languages) { if (!Object.prototype.hasOwnProperty.call(result, lang)) { - throw new Error("AI 返回的 key 不完整"); + throw new Error("AI 返回的语言 key 不完整"); } const v = result[lang]; - if (typeof v !== "string") { - throw new Error("AI 返回的某些值类型不是字符串"); + if (!v || typeof v !== "object" || Array.isArray(v)) { + throw new Error("AI 返回的语言值应为对象"); + } + const langMap = v as Record; + for (const it of items) { + if (!Object.prototype.hasOwnProperty.call(langMap, it.key)) { + throw new Error("AI 返回缺少某些条目的翻译"); + } + if (typeof langMap[it.key] !== "string") { + throw new Error("AI 返回的某些值类型不是字符串"); + } } } - return languages.reduce>((acc, l) => { - acc[l] = String((result as any)[l] ?? ""); - return acc; - }, {}); + const out: Record> = {}; + for (const lang of languages) { + const lm = result[lang] as Record; + const mapped: Record = {}; + for (const it of items) mapped[it.key] = String(lm[it.key] ?? ""); + out[lang] = mapped; + } + return out; } diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index e3e04ae..83b2d7d 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -35,6 +35,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useClipboard } from "@/hooks/use-clipboard"; import { toast } from "sonner"; +import { Checkbox } from "@/components/ui/checkbox"; export default function Editor() { const { id: projectId } = useParams(); @@ -54,7 +55,9 @@ export default function Editor() { const [caseSensitive, setCaseSensitive] = useState(false); const [fullMatch, setFullMatch] = useState(false); const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); + const [aiBulkOpen, setAiBulkOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); const { copy } = useClipboard(); @@ -145,15 +148,30 @@ export default function Editor() { TableFoot: (props: React.HTMLAttributes) => , }), []); + const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]); + const MAX_AI_ITEMS = 50; + const headerContent = useCallback(() => ( + + { + const next = new Set(); + if (checked) { + for (const en of entries) next.add(en.path); + } + setSelected(next); + }} + /> + 翻译条目名称 {languages.map((lang) => ( {lang} ))} 操作 - ), [languages, colWidth]); + ), [languages, colWidth, allSelected, entries]); const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { const handleCopy = () => { @@ -163,6 +181,18 @@ export default function Editor() { return ( <> + + { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(entry.path); else next.delete(entry.path); + return next; + }); + }} + /> + +
+ + {selected.size > MAX_AI_ITEMS && ( + 最多支持 {MAX_AI_ITEMS} 条 + )} + +
setAiModal((cur) => (cur ? { ...cur, open: v } : cur))} languages={languages} - path={aiModal?.path ?? ""} + paths={aiModal?.path ? [aiModal.path] : []} prompt={project?.preferences?.aiPrompt} 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] }; + const next = { ...prev } as Record; + const langMap = translations[lang] || {}; + for (const [k, v] of Object.entries(langMap)) next[k] = v; updates[lang] = next; await upsertLanguageTranslations(projectId, lang, next); }) @@ -419,6 +499,34 @@ export default function Editor() { }} /> + { + if (!projectId || selected.size === 0) return; + try { + const updatesByLang: Record> = {}; + await Promise.all( + languages.map(async (lang) => { + const prev = valuesByLang[lang] ?? {}; + const next = { ...prev } as Record; + const langMap = translations[lang] || {}; + for (const [k, v] of Object.entries(langMap)) next[k] = v; + updatesByLang[lang] = next; + await upsertLanguageTranslations(projectId, lang, next); + }) + ); + setValuesByLang((old) => ({ ...old, ...updatesByLang })); + } catch (e) { + setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败"); + throw e; + } + }} + /> + setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} @@ -427,6 +535,8 @@ export default function Editor() { onConfirm={async (name) => { if (!projectId || !structure || !addModal) return; const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name); + console.log("nextRoot", addModal, nextRoot); + await upsertStructure({ projectId, root: nextRoot }); setStructure({ projectId, root: nextRoot }); }}