feat: add recipe translation tool with AI translation and settings management

- Add Word document parsing and translation export
- Add database configuration management
- Add AI/LLM translation configuration
- Add translation vocabulary management
- Add export settings (colors, page orientation, headers)
- Separate database and AI configurations
- Add debounced search to prevent database overload
This commit is contained in:
2026-04-12 23:50:54 +08:00
parent 57e119635e
commit a249fb038c
24 changed files with 3383 additions and 70 deletions
+460
View File
@@ -0,0 +1,460 @@
"use client";
import {useState, useEffect, useCallback, useRef} from "react";
import {
Plus,
Search,
Loader2,
Pencil,
Trash2,
Sparkles,
ChevronLeft,
ChevronRight,
X,
} from "lucide-react";
import {Button} from "@/components/ui/button";
import {useToast} from "@/components/ui/toast";
interface WordItem {
id: number;
word: string;
english: string;
createTime?: string;
updateTime?: string;
}
interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
// Modal 组件
function Modal({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 animate-in fade-in duration-200"
onClick={onClose}
/>
{/* Content */}
<div
className="relative z-10 w-full max-w-md rounded-lg bg-background p-6 shadow-lg animate-in zoom-in-95 fade-in duration-200">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">{title}</h3>
<button
onClick={onClose}
className="rounded-sm p-1 hover:bg-muted"
>
<X className="h-4 w-4"/>
</button>
</div>
{children}
</div>
</div>
);
}
export default function TranslationPage() {
const [words, setWords] = useState<WordItem[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
});
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingWord, setEditingWord] = useState<WordItem | null>(null);
const [newWord, setNewWord] = useState({word: "", english: ""});
const [editForm, setEditForm] = useState({word: "", english: ""});
const [aiTranslating, setAiTranslating] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const {addToast} = useToast();
// 搜索防抖定时器
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
// 禁止背景滚动
useEffect(() => {
if (showAddModal || showEditModal) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [showAddModal, showEditModal]);
// 获取词组列表
const fetchWords = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
pageSize: pagination.pageSize.toString(),
});
if (search) params.set("search", search);
const response = await fetch(`/api/words?${params}`);
const result = await response.json();
if (result.success) {
setWords(result.data);
setPagination(result.pagination);
} else {
setError(result.error || "获取数据失败");
}
} catch (error) {
console.error("Failed to fetch words:", error);
setError("网络错误,请检查连接");
} finally {
setLoading(false);
}
}, [pagination.page, pagination.pageSize, search]);
useEffect(() => {
fetchWords();
}, [fetchWords]);
// 搜索(防抖 300ms
const handleSearch = () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
searchTimerRef.current = setTimeout(() => {
setPagination((prev) => ({...prev, page: 1}));
fetchWords();
}, 300);
};
// 添加词组
const handleAdd = async () => {
if (!newWord.word || !newWord.english) return;
setSaving(true);
try {
const response = await fetch("/api/words", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(newWord),
});
if (response.ok) {
setNewWord({word: "", english: ""});
setShowAddModal(false);
fetchWords();
}
} catch (error) {
console.error("Failed to add word:", error);
} finally {
setSaving(false);
}
};
// 打开编辑弹窗
const handleEdit = (word: WordItem) => {
setEditingWord(word);
setEditForm({word: word.word, english: word.english});
setShowEditModal(true);
};
// 保存编辑
const handleSave = async () => {
if (!editingWord || !editForm.word || !editForm.english) return;
setSaving(true);
try {
const response = await fetch(`/api/words/${editingWord.id}`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(editForm),
});
if (response.ok) {
setShowEditModal(false);
setEditingWord(null);
fetchWords();
}
} catch (error) {
console.error("Failed to update word:", error);
} finally {
setSaving(false);
}
};
// 删除词组
const handleDelete = async (id: number) => {
if (!confirm("确定要删除这个词组吗?")) return;
try {
const response = await fetch(`/api/words/${id}`, {
method: "DELETE",
});
if (response.ok) {
fetchWords();
}
} catch (error) {
console.error("Failed to delete word:", error);
}
};
// AI 翻译
const handleAiTranslate = async (target: "add" | "edit") => {
const source = target === "add" ? newWord.word : editForm.word;
if (!source.trim()) return;
setAiTranslating(true);
try {
// 使用 AI 翻译 API
const response = await fetch(`/api/ai/translate?word=${encodeURIComponent(source.trim())}`);
const result = await response.json();
if (result.english) {
if (target === "add") {
setNewWord((prev) => ({...prev, english: result.english}));
} else {
setEditForm((prev) => ({...prev, english: result.english}));
}
} else {
addToast({type: "error", title: "翻译失败", description: result.error || "未找到翻译结果"});
}
} catch (error) {
console.error("AI translation error:", error);
addToast({type: "error", title: "翻译失败", description: "请检查 AI 配置"});
} finally {
setAiTranslating(false);
}
};
return (
<div className="space-y-6">
{/*标题和按钮*/}
<div className={"flex items-center justify-between gap-2"}>
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="mr-2 h-4 w-4"/>
</Button>
</div>
{/* 搜索输入框 */}
<div className="flex flex-col gap-4 sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索中文或英文..."
className="w-full rounded-md border border-input bg-background py-2 pl-10 pr-4 text-sm"
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchWords} className="mt-2">
</Button>
</div>
)}
{/* 词组列表 */}
<div className="rounded-lg border">
<div className="max-h-[400px] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="w-32 px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-6 w-6 animate-spin"/>
</td>
</tr>
) : words.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
words.map((word) => (
<tr key={word.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">{word.word}</td>
<td className="px-4 py-3">{word.english}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(word)}>
<Pencil className="h-4 w-4"/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(word.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4"/>
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分页 */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between border-t bg-muted/30 px-4 py-3">
<span className="text-sm text-muted-foreground">
{pagination.total} {pagination.page}/{pagination.totalPages}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPagination((prev) => ({...prev, page: prev.page - 1}))}
disabled={pagination.page <= 1}
>
<ChevronLeft className="h-4 w-4"/>
</Button>
<span className="text-sm">
{pagination.page} / {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPagination((prev) => ({...prev, page: prev.page + 1}))}
disabled={pagination.page >= pagination.totalPages}
>
<ChevronRight className="h-4 w-4"/>
</Button>
</div>
</div>
)}
</div>
{/* 添加弹窗 */}
<Modal open={showAddModal} onClose={() => setShowAddModal(false)} title="添加词组">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={newWord.word}
onChange={(e) => setNewWord({...newWord, word: e.target.value})}
placeholder="输入中文"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="flex gap-2">
<input
type="text"
value={newWord.english}
onChange={(e) => setNewWord({...newWord, english: e.target.value})}
placeholder="输入英文"
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
<Button
variant="outline"
onClick={() => handleAiTranslate("add")}
disabled={aiTranslating || !newWord.word}
title="AI 翻译"
>
{aiTranslating ? <Loader2 className="h-4 w-4 animate-spin"/> :
<Sparkles className="h-4 w-4"/>}
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowAddModal(false)}>
</Button>
<Button onClick={handleAdd} disabled={saving || !newWord.word || !newWord.english}>
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</Modal>
{/* 编辑弹窗 */}
<Modal open={showEditModal} onClose={() => setShowEditModal(false)} title="编辑词组">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={editForm.word}
onChange={(e) => setEditForm({...editForm, word: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="flex gap-2">
<input
type="text"
value={editForm.english}
onChange={(e) => setEditForm({...editForm, english: e.target.value})}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
<Button
variant="outline"
onClick={() => handleAiTranslate("edit")}
disabled={aiTranslating || !editForm.word}
title="AI 翻译"
>
{aiTranslating ? <Loader2 className="h-4 w-4 animate-spin"/> :
<Sparkles className="h-4 w-4"/>}
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowEditModal(false)}>
</Button>
<Button onClick={handleSave} disabled={saving || !editForm.word || !editForm.english}>
{saving ? "保存中..." : "保存"}
</Button>
</div>
</div>
</Modal>
</div>
);
}