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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user