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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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