Feat: 新增 AI 翻译功能,导出 JSON 复制到剪贴板

This commit is contained in:
2025-11-04 17:50:57 +08:00
parent e1a5be8722
commit f0c9dbfd36
10 changed files with 348 additions and 11 deletions
+111
View File
@@ -0,0 +1,111 @@
import { memo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { requestTranslations } from "@/lib/ai";
type Props = {
open: boolean;
onOpenChange: (next: boolean) => void;
languages: string[];
path: string;
onConfirm: (result: Record<string, string>) => Promise<void> | void;
};
function AiTranslateModalImpl({ open, onOpenChange, languages, path, onConfirm }: Props) {
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const payload = text.trim();
if (!payload) {
setError("请输入待翻译文本");
return;
}
if (languages.length === 0) {
setError("当前项目暂无目标语言");
return;
}
setLoading(true);
setError(null);
try {
const result = await requestTranslations({ text: payload, languages });
// 二次校验 keys 完整性
for (const l of languages) {
if (!Object.prototype.hasOwnProperty.call(result, l)) {
throw new Error("AI 返回的 key 不完整");
}
}
await onConfirm(result);
setText("");
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-2xl">
<DialogHeader>
<DialogTitle>{`AI 翻译 — ${path || "条目"}`}</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>
<div className="text-xs text-muted-foreground">
目标语言:{languages.join(", ") || "无"}
</div>
{error && (
<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}>
{loading ? "翻译中..." : "生成翻译"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export const AiTranslateModal = memo(AiTranslateModalImpl);
@@ -1,4 +1,5 @@
import { memo, useEffect, useMemo, useState } from "react";
import { Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -8,6 +9,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { unflattenValues } from "@/lib/i18n-structure";
import { useClipboard } from "@/hooks/use-clipboard";
type Props = {
open: boolean;
@@ -25,6 +27,7 @@ function ExportLanguageModalImpl({
orderedPaths,
}: Props) {
const [selected, setSelected] = useState<string>("");
const { copied, copy } = useClipboard({ resetAfterMs: 1500 });
useEffect(() => {
if (open) {
@@ -102,6 +105,12 @@ function ExportLanguageModalImpl({
URL.revokeObjectURL(url);
}
const handleCopy = () => {
if (!jsonText) return;
copy(jsonText);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
@@ -141,6 +150,9 @@ function ExportLanguageModalImpl({
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button onClick={handleCopy} variant="outline" disabled={!selected || !jsonText}>
{copied ? (<><Check /> 已复制</>) : "复制 JSON"}
</Button>
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
导出 JSON
</Button>