I18n-Translate-It/src/components/biz/ai-translate-modal.tsx

253 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { requestTranslations } from "@/lib/ai";
import { ButtonGroup } from "../ui/button-group";
type Props = {
open: boolean;
onOpenChange: (next: boolean) => void;
languages: string[];
paths: string[];
prompt: string | undefined;
initialSelectedLanguages?: string[];
getExistingByPath?: (path: string) => Record<string, string>;
onConfirm: (
result: Record<string, Record<string, string>>,
options: { overwrite: boolean; selectedLanguages: string[] }
) => Promise<void> | void;
};
function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, initialSelectedLanguages, getExistingByPath, onConfirm }: Props) {
const [inputsByKey, setInputsByKey] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedLangs, setSelectedLangs] = useState<string[]>([]);
const [overwrite, setOverwrite] = useState(false);
const [existingCache, setExistingCache] = useState<Record<string, { lang: string; text: string }[]>>({});
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);
// 初始化默认勾选语言
const initSelected = (initialSelectedLanguages && initialSelectedLanguages.length > 0)
? initialSelectedLanguages
: languages;
setSelectedLangs(initSelected);
setOverwrite(false);
setExistingCache({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, paths.join("|")]);
function loadExistingForPath(path: string) {
if (existingCache[path]) return;
const map = getExistingByPath?.(path) ?? {};
const arr = Object.entries(map)
.map(([lang, text]) => ({ lang, text }))
.filter((it) => typeof it.text === "string" && it.text.trim() !== "");
setExistingCache((old) => ({ ...old, [path]: arr }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
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) {
setError("当前项目暂无目标语言");
return;
}
if (selectedLangs.length === 0) {
setError("请选择目标语言");
return;
}
setLoading(true);
setError(null);
try {
const result = await requestTranslations({ items, languages: selectedLangs, prompt });
await onConfirm(result, { overwrite, selectedLanguages: selectedLangs });
setInputsByKey({});
onOpenChange(false);
} catch (e) {
setError((e as Error)?.message ?? "AI 翻译失败");
} finally {
setLoading(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!loading) {
onOpenChange(v);
if (!v) setError(null);
}
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{`AI 翻译 — 已选 ${paths.length}`}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<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>
<ButtonGroup className="w-full">
<Input
className="grow"
value={inputsByKey[p] ?? ""}
onChange={(e) =>
setInputsByKey((old) => ({ ...old, [p]: e.target.value }))
}
placeholder="请输入原文本"
aria-label={`原文 ${p}`}
/>
<DropdownMenu
onOpenChange={(open) => {
if (open) loadExistingForPath(p);
}}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
aria-label="使用已有翻译"
>
使
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-96 whitespace-normal wrap-break-word"
>
{(existingCache[p]?.length ?? 0) === 0 ? (
<DropdownMenuItem disabled></DropdownMenuItem>
) : (
(existingCache[p] || []).map((opt) => (
<>
<DropdownMenuLabel>
{opt.lang}
</DropdownMenuLabel>
<DropdownMenuItem
key={`${p}:${opt.lang}`}
onClick={() =>
setInputsByKey((old) => ({
...old,
[p]: opt.text,
}))
}
title={opt.text}
>
<span className="truncate block">{opt.text}</span>
</DropdownMenuItem>
</>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</div>
))}
</div>
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
</div>
<div className="flex flex-wrap gap-3">
{languages.map((lang) => {
const checked = selectedLangs.includes(lang);
return (
<label
key={lang}
className="inline-flex items-center gap-2 select-none"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setSelectedLangs((old) => {
const set = new Set(old);
if (v) set.add(lang);
else set.delete(lang);
return Array.from(set);
});
}}
/>
<span className="text-sm">{lang}</span>
</label>
);
})}
</div>
</div>
<label className="inline-flex items-center gap-2 select-none">
<Checkbox
checked={overwrite}
onCheckedChange={(v) => setOverwrite(!!v)}
/>
<span className="text-sm"></span>
</label>
{(error || overLimit) && (
<div className="text-sm text-red-600" role="alert">
{error}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
setError(null);
}}
disabled={loading}
>
</Button>
<Button type="submit" disabled={loading || overLimit}>
{loading ? "翻译中..." : "生成翻译"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export const AiTranslateModal = memo(AiTranslateModalImpl);