Feat: 多选条目翻译,最多同时翻译 50 条

This commit is contained in:
奇趣保罗 2025-11-05 02:05:58 +08:00
parent 85d197f149
commit 9766bc2411
4 changed files with 192 additions and 47 deletions

View File

@ -1,4 +1,4 @@
import { memo, useState } from "react"; import { memo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@ -14,21 +14,36 @@ type Props = {
open: boolean; open: boolean;
onOpenChange: (next: boolean) => void; onOpenChange: (next: boolean) => void;
languages: string[]; languages: string[];
path: string; paths: string[];
prompt: string | undefined; prompt: string | undefined;
onConfirm: (result: Record<string, string>) => Promise<void> | void; onConfirm: (result: Record<string, Record<string, string>>) => Promise<void> | void;
}; };
function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onConfirm }: Props) { function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, onConfirm }: Props) {
const [text, setText] = useState(""); const [inputsByKey, setInputsByKey] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const MAX_ITEMS = 50;
const overLimit = paths.length > MAX_ITEMS;
useEffect(() => {
if (open) {
const init: Record<string, string> = {};
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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const payload = text.trim(); if (overLimit) {
if (!payload) { setError(`一次最多支持 ${MAX_ITEMS}`);
setError("请输入待翻译文本"); return;
}
const items = paths.map((key) => ({ key, text: (inputsByKey[key] ?? "").trim() }));
if (items.some((it) => !it.text)) {
setError("请为所有条目输入原文");
return; return;
} }
if (languages.length === 0) { if (languages.length === 0) {
@ -38,15 +53,9 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await requestTranslations({ text: payload, languages, prompt }); const result = await requestTranslations({ items, languages, prompt });
// 二次校验 keys 完整性
for (const l of languages) {
if (!Object.prototype.hasOwnProperty.call(result, l)) {
throw new Error("AI 返回的 key 不完整");
}
}
await onConfirm(result); await onConfirm(result);
setText(""); setInputsByKey({});
onOpenChange(false); onOpenChange(false);
} catch (e) { } catch (e) {
setError((e as Error)?.message ?? "AI 翻译失败"); setError((e as Error)?.message ?? "AI 翻译失败");
@ -65,24 +74,29 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
} }
}} }}
> >
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{`AI 翻译 — ${path || "条目"}`}</DialogTitle> <DialogTitle>{`AI 翻译 — 已选 ${paths.length}`}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div className="space-y-4 max-h-[50vh] overflow-auto pr-1">
<label className="text-sm text-muted-foreground"></label> {paths.map((p) => (
<div key={p} className="flex flex-col gap-2">
<div className="truncate font-mono text-xs text-muted-foreground">{p}</div>
<Input <Input
value={text} className="grow"
onChange={(e) => setText(e.target.value)} value={inputsByKey[p] ?? ""}
onChange={(e) => setInputsByKey((old) => ({ ...old, [p]: e.target.value }))}
placeholder="请输入原文本" placeholder="请输入原文本"
aria-label="待翻译文本" aria-label={`原文 ${p}`}
/> />
</div> </div>
))}
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{languages.join(", ") || "无"} {languages.join(", ") || "无"}
</div> </div>
{error && ( {(error || overLimit) && (
<div className="text-sm text-red-600" role="alert">{error}</div> <div className="text-sm text-red-600" role="alert">{error}</div>
)} )}
<DialogFooter> <DialogFooter>
@ -97,7 +111,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
> >
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading || overLimit}>
{loading ? "翻译中..." : "生成翻译"} {loading ? "翻译中..." : "生成翻译"}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -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", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: 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", "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: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:

View File

@ -1,7 +1,7 @@
import OpenAI from "openai"; import OpenAI from "openai";
export type AiTranslateParams = { export type AiTranslateParams = {
text: string; items: { key: string; text: string }[];
languages: string[]; languages: string[];
model?: string; model?: string;
prompt?: string; prompt?: string;
@ -18,11 +18,11 @@ function getClient() {
} }
export async function requestTranslations({ export async function requestTranslations({
text, items,
languages, languages,
model, model,
prompt, prompt,
}: AiTranslateParams): Promise<Record<string, string>> { }: AiTranslateParams): Promise<Record<string, Record<string, string>>> {
const client = getClient(); const client = getClient();
const mdl = const mdl =
model || model ||
@ -31,19 +31,25 @@ export async function requestTranslations({
"openai/gpt-5"; "openai/gpt-5";
const targetList = JSON.stringify(languages); const targetList = JSON.stringify(languages);
const itemsJson = JSON.stringify(items, null, 0);
const keysJson = JSON.stringify(items.map((it) => it.key));
const instruction = [ const instruction = [
"你是一个专业的翻译助手。", "你是一个专业的翻译助手。",
`请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`, `请将多条原文同时翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
"翻译偏好:", "翻译偏好:",
prompt, prompt,
"严格要求:", "严格要求:",
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。", "- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
"- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。", "- JSON 的顶层键必须严格等于目标语言代码;其值必须是一个对象,键为条目 key值为该 key 的翻译纯文本。",
"- 如果某一语言确实无法翻译,则将该键的值设置为原文。", "- 对所有提供的 key 都必须返回译文;若无法翻译,使用原文。",
'示例:{"en":"Cat","zh-Hans":"猫"}', '返回 JSON 示例:{"en":{"a.b":"Text"},"zh-Hans":{"a.b":"文本"}}',
].join("\n"); ].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({ const response = await client.responses.create({
model: mdl, model: mdl,
@ -74,16 +80,29 @@ export async function requestTranslations({
const result = obj as Record<string, unknown>; const result = obj as Record<string, unknown>;
for (const lang of languages) { for (const lang of languages) {
if (!Object.prototype.hasOwnProperty.call(result, lang)) { if (!Object.prototype.hasOwnProperty.call(result, lang)) {
throw new Error("AI 返回的 key 不完整"); throw new Error("AI 返回的语言 key 不完整");
} }
const v = result[lang]; const v = result[lang];
if (typeof v !== "string") { if (!v || typeof v !== "object" || Array.isArray(v)) {
throw new Error("AI 返回的语言值应为对象");
}
const langMap = v as Record<string, unknown>;
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 返回的某些值类型不是字符串"); throw new Error("AI 返回的某些值类型不是字符串");
} }
} }
}
return languages.reduce<Record<string, string>>((acc, l) => {
acc[l] = String((result as any)[l] ?? ""); const out: Record<string, Record<string, string>> = {};
return acc; for (const lang of languages) {
}, {}); const lm = result[lang] as Record<string, unknown>;
const mapped: Record<string, string> = {};
for (const it of items) mapped[it.key] = String(lm[it.key] ?? "");
out[lang] = mapped;
}
return out;
} }

View File

@ -35,6 +35,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@/hooks/use-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
export default function Editor() { export default function Editor() {
const { id: projectId } = useParams(); const { id: projectId } = useParams();
@ -54,7 +55,9 @@ export default function Editor() {
const [caseSensitive, setCaseSensitive] = useState(false); const [caseSensitive, setCaseSensitive] = useState(false);
const [fullMatch, setFullMatch] = useState(false); const [fullMatch, setFullMatch] = useState(false);
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
const [aiBulkOpen, setAiBulkOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const { copy } = useClipboard(); const { copy } = useClipboard();
@ -145,15 +148,30 @@ export default function Editor() {
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />, TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
}), []); }), []);
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
const MAX_AI_ITEMS = 50;
const headerContent = useCallback(() => ( const headerContent = useCallback(() => (
<tr> <tr>
<th style={{ width: 36 }} className="px-3 py-2">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
const next = new Set<string>();
if (checked) {
for (const en of entries) next.add(en.path);
}
setSelected(next);
}}
/>
</th>
<th style={{ width: 200 }} className="text-left px-3 py-2"></th> <th style={{ width: 200 }} className="text-left px-3 py-2"></th>
{languages.map((lang) => ( {languages.map((lang) => (
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2">{lang}</th> <th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2">{lang}</th>
))} ))}
<th style={{ width: 80 }} className="text-left px-3 py-2"></th> <th style={{ width: 80 }} className="text-left px-3 py-2"></th>
</tr> </tr>
), [languages, colWidth]); ), [languages, colWidth, allSelected, entries]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
const handleCopy = () => { const handleCopy = () => {
@ -163,6 +181,18 @@ export default function Editor() {
return ( return (
<> <>
<td style={{ width: 36 }} className="px-3 py-2">
<Checkbox
checked={selected.has(entry.path)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(entry.path); else next.delete(entry.path);
return next;
});
}}
/>
</td>
<td style={{ width: 200 }} className="px-3 py-2 font-mono"> <td style={{ width: 200 }} className="px-3 py-2 font-mono">
<button type="button" className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制"> <button type="button" className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path} {entry.path}
@ -344,6 +374,55 @@ export default function Editor() {
</Button> </Button>
</form> </form>
<div className="mb-2 flex items-center gap-2">
<Button
variant="outline"
disabled={selected.size === 0 || selected.size > MAX_AI_ITEMS}
onClick={() => setAiBulkOpen(true)}
title={selected.size > MAX_AI_ITEMS ? `最多支持 ${MAX_AI_ITEMS}` : "对所选条目进行 AI 翻译"}
>
<Languages />
AI {selected.size}
</Button>
{selected.size > MAX_AI_ITEMS && (
<span className="text-xs text-red-600"> {MAX_AI_ITEMS} </span>
)}
<Button
variant="outline-destructive"
disabled={selected.size === 0}
onClick={async () => {
if (!projectId || !structure) return;
if (selected.size === 0) return;
if (!confirm(`确认删除所选 ${selected.size} 个条目?此操作会移除所有语言下的这些键`)) return;
try {
let nextRoot = structure.root;
for (const p of selected) {
nextRoot = removeEntryAtPath(nextRoot, p);
}
await upsertStructure({ projectId, root: nextRoot });
// 从所有语言删除这些键
await Promise.all(Array.from(selected).map((p) => deleteEntryFromAllLanguages(projectId, p)));
setStructure({ projectId, root: nextRoot });
setValuesByLang((old) => {
const copy: typeof old = {};
for (const [langKey, vals] of Object.entries(old)) {
const next = { ...vals } as Record<string, string>;
for (const p of selected) delete next[p];
copy[langKey] = next;
}
return copy;
});
setSelected(new Set());
} catch (e) {
setPageError((e as Error)?.message ?? "批量删除失败");
}
}}
title="删除所选条目"
>
<Trash2 />
</Button>
</div>
<div className="flex-1 border rounded-md"> <div className="flex-1 border rounded-md">
<TableVirtuoso <TableVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
@ -395,17 +474,18 @@ export default function Editor() {
open={!!aiModal?.open} open={!!aiModal?.open}
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))} onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
languages={languages} languages={languages}
path={aiModal?.path ?? ""} paths={aiModal?.path ? [aiModal.path] : []}
prompt={project?.preferences?.aiPrompt} prompt={project?.preferences?.aiPrompt}
onConfirm={async (translations) => { onConfirm={async (translations) => {
if (!projectId || !aiModal) return; if (!projectId || !aiModal) return;
const targetPath = aiModal.path;
const updates: Record<string, Record<string, string>> = {}; const updates: Record<string, Record<string, string>> = {};
try { try {
await Promise.all( await Promise.all(
languages.map(async (lang) => { languages.map(async (lang) => {
const prev = valuesByLang[lang] ?? {}; const prev = valuesByLang[lang] ?? {};
const next = { ...prev, [targetPath]: translations[lang] }; const next = { ...prev } as Record<string, string>;
const langMap = translations[lang] || {};
for (const [k, v] of Object.entries(langMap)) next[k] = v;
updates[lang] = next; updates[lang] = next;
await upsertLanguageTranslations(projectId, lang, next); await upsertLanguageTranslations(projectId, lang, next);
}) })
@ -419,6 +499,34 @@ export default function Editor() {
}} }}
/> />
<AiTranslateModal
open={aiBulkOpen}
onOpenChange={setAiBulkOpen}
languages={languages}
paths={Array.from(selected)}
prompt={project?.preferences?.aiPrompt}
onConfirm={async (translations) => {
if (!projectId || selected.size === 0) return;
try {
const updatesByLang: Record<string, Record<string, string>> = {};
await Promise.all(
languages.map(async (lang) => {
const prev = valuesByLang[lang] ?? {};
const next = { ...prev } as Record<string, string>;
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;
}
}}
/>
<EntryNameModal <EntryNameModal
open={!!addModal?.open} open={!!addModal?.open}
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
@ -427,6 +535,8 @@ export default function Editor() {
onConfirm={async (name) => { onConfirm={async (name) => {
if (!projectId || !structure || !addModal) return; if (!projectId || !structure || !addModal) return;
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name); const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
console.log("nextRoot", addModal, nextRoot);
await upsertStructure({ projectId, root: nextRoot }); await upsertStructure({ projectId, root: nextRoot });
setStructure({ projectId, root: nextRoot }); setStructure({ projectId, root: nextRoot });
}} }}