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 { 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<string, string>) => Promise<void> | void;
onConfirm: (result: Record<string, Record<string, string>>) => Promise<void> | 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<Record<string, string>>({});
const [loading, setLoading] = useState(false);
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) {
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
}
}}
>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{`AI 翻译 — ${path || "条目"}`}</DialogTitle>
<DialogTitle>{`AI 翻译 — 已选 ${paths.length}`}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-sm text-muted-foreground"></label>
<Input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请输入原文本"
aria-label="待翻译文本"
/>
<div className="space-y-4 max-h-[50vh] overflow-auto pr-1">
{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
className="grow"
value={inputsByKey[p] ?? ""}
onChange={(e) => setInputsByKey((old) => ({ ...old, [p]: e.target.value }))}
placeholder="请输入原文本"
aria-label={`原文 ${p}`}
/>
</div>
))}
</div>
<div className="text-xs text-muted-foreground">
{languages.join(", ") || "无"}
</div>
{error && (
{(error || overLimit) && (
<div className="text-sm text-red-600" role="alert">{error}</div>
)}
<DialogFooter>
@ -97,7 +111,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
>
</Button>
<Button type="submit" disabled={loading}>
<Button type="submit" disabled={loading || overLimit}>
{loading ? "翻译中..." : "生成翻译"}
</Button>
</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",
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:

View File

@ -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<Record<string, string>> {
}: AiTranslateParams): Promise<Record<string, Record<string, string>>> {
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<string, unknown>;
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<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 返回的某些值类型不是字符串");
}
}
}
return languages.reduce<Record<string, string>>((acc, l) => {
acc[l] = String((result as any)[l] ?? "");
return acc;
}, {});
const out: Record<string, Record<string, string>> = {};
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";
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<Set<string>>(new Set());
const { copy } = useClipboard();
@ -145,15 +148,30 @@ export default function Editor() {
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(() => (
<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>
{languages.map((lang) => (
<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>
</tr>
), [languages, colWidth]);
), [languages, colWidth, allSelected, entries]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
const handleCopy = () => {
@ -163,6 +181,18 @@ export default function Editor() {
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">
<button type="button" className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path}
@ -344,6 +374,55 @@ export default function Editor() {
</Button>
</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">
<TableVirtuoso
ref={virtuosoRef}
@ -395,17 +474,18 @@ export default function Editor() {
open={!!aiModal?.open}
onOpenChange={(v) => 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<string, Record<string, string>> = {};
try {
await Promise.all(
languages.map(async (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;
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
open={!!addModal?.open}
onOpenChange={(v) => 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 });
}}