253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
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);
|
||
|
||
|