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
+175
View File
@@ -0,0 +1,175 @@
"use client";
import {useEffect, useState} from "react";
import {Loader2} from "lucide-react";
import {Button} from "@/components/ui/button";
interface AIConfig {
apiKey: string;
baseUrl: string;
model: string;
}
const defaultAIConfig: AIConfig = {
apiKey: "",
baseUrl: "https://api.openai.com/v1",
model: "gpt-3.5-turbo",
};
export default function AISettingsPage() {
const [config, setConfig] = useState<AIConfig>(defaultAIConfig);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<"success" | "error" | null>(null);
// 加载配置
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((result) => {
if (result.success && result.data) {
setConfig({
apiKey: result.data.ai?.apiKey || defaultAIConfig.apiKey,
baseUrl: result.data.ai?.baseUrl || defaultAIConfig.baseUrl,
model: result.data.ai?.model || defaultAIConfig.model,
});
}
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// 保存配置
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/config", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ai: config}),
});
const result = await response.json();
if (result.success) {
setTestResult("success");
setTimeout(() => setTestResult(null), 3000);
} else {
setTestResult("error");
}
} catch (error) {
console.error("Failed to save config:", error);
setTestResult("error");
} finally {
setSaving(false);
}
};
// 测试 AI 连接
const handleTestAI = async () => {
if (!config.apiKey) return;
// 先保存配置
await handleSave();
setTesting(true);
setTestResult(null);
try {
const response = await fetch(`/api/ai/translate?word=${encodeURIComponent("早餐")}`);
const result = await response.json();
if (result.english) {
setTestResult("success");
} else {
setTestResult("error");
}
} catch {
setTestResult("error");
} finally {
setTesting(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> AI </p>
</div>
<div className="h-40 animate-pulse rounded-lg bg-muted"/>
</div>
);
}
return (
<div className="space-y-6">
<div className={"flex items-center justify-between"}>
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> AI </p>
</div>
<div className={"flex items-center justify-between gap-2"}>
<div className="flex items-center gap-4">
<Button onClick={handleTestAI} disabled={testing || saving || !config.apiKey}>
{testing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>
...
</>
) : (
"测试并保存"
)}
</Button>
{testResult === "success" && (
<span className="text-sm text-green-600"></span>
)}
{testResult === "error" && (
<span className="text-sm text-destructive"> API Key</span>
)}
</div>
<div className="flex gap-3">
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium">API Key</label>
<input
type="password"
value={config.apiKey}
onChange={(e) => setConfig({...config, apiKey: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="sk-..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Base URL</label>
<input
type="text"
value={config.baseUrl}
onChange={(e) => setConfig({...config, baseUrl: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={config.model}
onChange={(e) => setConfig({...config, model: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="gpt-3.5-turbo"
/>
</div>
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import {useState, useEffect} from "react";
import {useToast} from "@/components/ui/toast";
import {Loader2} from "lucide-react";
import {Button} from "@/components/ui/button";
interface DBConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
}
const defaultDBConfig: DBConfig = {
host: "127.0.0.1",
port: 3306,
user: "root",
password: "",
database: "recipe_tools",
};
export default function DatabasePage() {
const [config, setConfig] = useState<DBConfig>(defaultDBConfig);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const {addToast} = useToast();
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((result) => {
if (result.success && result.data) {
setConfig({
host: result.data.db?.host || defaultDBConfig.host,
port: result.data.db?.port || defaultDBConfig.port,
user: result.data.db?.user || defaultDBConfig.user,
password: result.data.db?.password || defaultDBConfig.password,
database: result.data.db?.database || defaultDBConfig.database,
});
}
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/config", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({db: config}),
});
const result = await response.json();
if (result.success) {
addToast({type: "success", title: "数据库配置已保存"});
} else {
addToast({type: "error", title: "保存失败", description: result.error});
}
} catch {
addToast({type: "error", title: "保存失败"});
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> MySQL </p>
</div>
<div className="h-40 animate-pulse rounded-lg bg-muted"/>
</div>
);
}
return (
<div className="space-y-6">
<div className={"flex items-center justify-between"}>
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> MySQL </p>
</div>
<div className="flex gap-3">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={config.host}
onChange={(e) => setConfig({...config, host: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="127.0.0.1"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={config.port}
onChange={(e) => setConfig({...config, port: parseInt(e.target.value) || 3306})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="3306"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={config.user}
onChange={(e) => setConfig({...config, user: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="root"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="password"
value={config.password}
onChange={(e) => setConfig({...config, password: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="******"
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium"></label>
<input
type="text"
value={config.database}
onChange={(e) => setConfig({...config, database: e.target.value})}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="recipe_tools"
/>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import {FileText} from "lucide-react";
export default function ExportPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> Word </p>
</div>
<div className="rounded-lg border bg-card p-8 text-center">
<FileText className="mx-auto h-12 w-12 text-muted-foreground"/>
<p className="mt-4 text-sm text-muted-foreground">
...
</p>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Database,
Globe,
FileText,
ChevronRight,
UtensilsCrossed,
Bandage
} from "lucide-react";
import { Button } from "@/components/ui/button";
const settingsMenu = [
{
title: "数据库配置",
icon: Database,
href: "/settings/database",
},
{
title: "翻译词库",
icon: Globe,
href: "/settings/translation",
},
{
title: "导出设置",
icon: FileText,
href: "/settings/export",
},
{
title: "大模型设置",
icon: Bandage,
href: "/settings/ai",
},
];
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<div className="min-h-screen bg-background">
<header className="border-b bg-card">
<div className="mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<UtensilsCrossed className="h-5 w-5"/>
</div>
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<Link href="/">
<Button variant="ghost" size="sm">
</Button>
</Link>
</div>
</div>
</header>
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="flex gap-8">
{/* 左侧菜单 */}
<aside className="w-64 shrink-0">
<nav className="space-y-1">
{settingsMenu.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
<Icon className="h-4 w-4"/>
{item.title}
{isActive && <ChevronRight className="ml-auto h-4 w-4"/>}
</Link>
);
})}
</nav>
</aside>
{/* 右侧内容 */}
<main className="flex-1 rounded-xl border bg-card p-6">
{children}
</main>
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import {useEffect} from "react";
import {useRouter} from "next/navigation";
import {Loader2} from "lucide-react";
// 主设置页面 - 重定向到数据库设置
export default function SettingsPage() {
const router = useRouter();
useEffect(() => {
router.replace("/settings/database");
}, [router]);
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground"/>
</div>
);
}
+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>
);
}