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
+42 -28
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>
+2
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: