Feat: 多选条目翻译,最多同时翻译 50 条
This commit is contained in:
parent
85d197f149
commit
9766bc2411
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue