Files
hanhan a249fb038c 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
2026-04-12 23:50:54 +08:00

460 lines
18 KiB
TypeScript
Raw Permalink 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.
"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>
);
}