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
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { batchTranslateWithAI, translateWithAI } from "@/lib/ai-translate";
// 单个翻译
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const word = searchParams.get("word");
if (!word) {
return NextResponse.json({ error: "缺少 word 参数" }, { status: 400 });
}
try {
const translation = await translateWithAI(word);
if (!translation) {
return NextResponse.json({ error: "翻译失败" }, { status: 500 });
}
return NextResponse.json({
word,
english: translation,
});
} catch (error) {
console.error("AI translation error:", error);
return NextResponse.json(
{ error: String(error) },
{ status: 500 }
);
}
}
// 批量翻译
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { words } = body as { words: string[] };
if (!Array.isArray(words) || words.length === 0) {
return NextResponse.json({ error: "缺少 words 参数(数组)" }, { status: 400 });
}
// 检查 API Key
if (!process.env.AI_API_KEY) {
return NextResponse.json({ error: "AI 配置未完成,请先在设置中配置 AI" }, { status: 500 });
}
const results = await batchTranslateWithAI(words);
// 转换为响应格式
const translations: Record<string, string | null> = {};
for (const word of words) {
translations[word] = results.get(word) || null;
}
return NextResponse.json({
translations,
total: words.length,
success: results.size,
});
} catch (error) {
console.error("AI batch translation error:", error);
return NextResponse.json(
{ error: String(error) },
{ status: 500 }
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { getConfig, saveDBConfig, saveAIConfig, type DBConfig, type AIConfig } from "@/lib/config";
// GET /api/config - 获取完整配置
export async function GET() {
try {
const config = getConfig();
return NextResponse.json({
success: true,
data: config,
});
} catch (error) {
return NextResponse.json(
{ success: false, error: "读取配置失败" },
{ status: 500 }
);
}
}
// POST /api/config - 保存配置
// Body 格式: { db?: DBConfig, ai?: AIConfig }
export async function POST(request: Request) {
try {
const body = await request.json();
const { db, ai } = body;
if (db) {
saveDBConfig(db);
}
if (ai) {
saveAIConfig(ai);
}
// 如果同时发送了完整配置
if (!db && !ai && body.db === undefined && body.ai === undefined) {
// 可能是旧的扁平格式,尝试兼容
if (body.apiKey !== undefined || body.baseUrl !== undefined || body.model !== undefined) {
saveAIConfig({
apiKey: body.apiKey,
baseUrl: body.baseUrl,
model: body.model,
});
}
}
return NextResponse.json({
success: true,
message: "配置已保存",
});
} catch (error) {
return NextResponse.json(
{ success: false, error: "保存失败" },
{ status: 500 }
);
}
}
+280
View File
@@ -0,0 +1,280 @@
import { NextRequest, NextResponse } from "next/server";
import mammoth from "mammoth";
import * as cheerio from "cheerio";
// 餐食类型
enum MealTypeCode {
BREAKFAST = "breakfast",
FRUIT = "fruit",
LUNCH = "lunch",
SNACK = "snack",
SPECIAL = "special",
}
// 语言名称项
interface LangName {
zh: string;
en?: string;
}
// 单个菜品
interface Dish {
id: string;
name: LangName;
}
// 单日食谱
interface DailyMeal {
dayCode: number;
mealCode: MealTypeCode;
dishList: Dish[];
}
// 一周食谱
interface WeeklyMenu {
menuId: string;
weekNumber: string;
title: LangName;
days: { code: number; name: LangName }[];
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
meals: DailyMeal[];
}
// 餐食类型配置
const MEAL_TYPES = [
{ code: MealTypeCode.BREAKFAST, label: "早餐" },
{ code: MealTypeCode.FRUIT, label: "水果餐" },
{ code: MealTypeCode.LUNCH, label: "中餐" },
{ code: MealTypeCode.SPECIAL, label: "体弱儿专属" },
{ code: MealTypeCode.SNACK, label: "午点" },
];
const WEEK_DAYS = [
{ code: 1, label: "周一" },
{ code: 2, label: "周二" },
{ code: 3, label: "周三" },
{ code: 4, label: "周四" },
{ code: 5, label: "周五" },
];
/**
* 使用 Cheerio 从 HTML 中提取表格原始数据
*/
function extractRawDataFromHtml(html: string): string[][][] {
const tableData: string[][][] = [];
const $ = cheerio.load(html);
const rows = $("table tr");
rows.each((rowIndex, row) => {
const rowData: string[][] = [];
const cells = $(row).find("td");
cells.each((cellIndex, cell) => {
// 直接获取 HTML 并处理
const rawHtml = $(cell).html() || "";
// 替换换行标签为真实换行符
const processed = rawHtml
.replace(/<\/p><p[^>]*>/g, "\n") // </p><p> → 换行
.replace(/<br\s*\/?>/gi, "\n")
.replace(/&nbsp;/g, " ");
// 移除剩余 HTML 标签
const text = processed.replace(/<[^>]+>/g, "");
// 按换行分割
const lines = text.split("\n").filter((line) => line.trim() !== "");
// 对每行应用顿号/逗号/分号分割
const finalItems: string[] = [];
lines.forEach((line) => {
finalItems.push(...splitCellContent(line));
});
rowData.push(finalItems);
});
if (rowData.length > 0) {
tableData.push(rowData);
}
});
return tableData;
}
/**
* 分割单元格内容为菜品列表
* 支持分隔符:顿号(、)、逗号(,)、分号(;)
*/
function splitCellContent(content: string): string[] {
if (!content || content.trim() === "") return [];
// 统一分隔符
const normalized = content
.replace(//g, "|")
.replace(/,/g, "|")
.replace(/、/g, "|")
.trim();
const parts = normalized.split("|");
return parts
.map((item) => item.trim())
.filter(Boolean);
}
/**
* 确定餐食类型
*/
function getMealType(mealName: string): MealTypeCode | null {
if (mealName.includes("早")) return MealTypeCode.BREAKFAST;
if (mealName.includes("水果")) return MealTypeCode.FRUIT;
if (mealName.includes("中") || mealName.includes("午餐")) return MealTypeCode.LUNCH;
if (mealName.includes("午点") || mealName.includes("点心")) return MealTypeCode.SNACK;
if (mealName.includes("体弱") || mealName.includes("晚")) return MealTypeCode.SPECIAL;
return null;
}
/**
* 从表格数据转换为一周食谱结构
*/
function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
const meals: DailyMeal[] = [];
// 校验表格数据完整性
if (tableData.length < 2 || tableData[0].length < 2) {
console.warn("警告:表格数据不完整");
return createEmptyMenu();
}
// 提取表头数据(星期几)
const weekdays = tableData[0].slice(1).map((cell) => cell[0] || "");
// 提取餐型行数据(从第二行开始)
const mealTypeRows = tableData.slice(1);
// 遍历每一天(列)
for (let dayIndex = 0; dayIndex < weekdays.length; dayIndex++) {
const dayName = weekdays[dayIndex];
if (!dayName) continue;
const columnIndex = dayIndex + 1; // 对应星期几的列索引
// 遍历每一行餐型
for (const row of mealTypeRows) {
const mealName = row[0]?.[0] || ""; // 餐型名称
if (!mealName) continue;
const mealType = getMealType(mealName);
if (!mealType) continue;
// 获取当前餐型的菜品列表
const mealItems = row[columnIndex] || [];
// 添加到当天餐食列表
meals.push({
dayCode: dayIndex + 1,
mealCode: mealType,
dishList: mealItems.map((item, idx) => ({
id: `${dayIndex + 1}-${mealType}-${idx + 1}`,
name: { zh: item },
})),
});
}
}
// 确保每天每餐都有记录
for (const day of WEEK_DAYS) {
for (const meal of MEAL_TYPES) {
const exists = meals.find(
(m) => m.dayCode === day.code && m.mealCode === meal.code
);
if (!exists) {
meals.push({ dayCode: day.code, mealCode: meal.code, dishList: [] });
}
}
}
// 构建 days 数组,使用实际解析出的星期
const days = weekdays
.filter(Boolean)
.map((name, index) => ({ code: index + 1, name: { zh: name } }));
return {
menuId: `menu-${Date.now()}`,
weekNumber: "",
title: { zh: "食谱", en: "Recipe" },
days: days,
mealCategories: MEAL_TYPES.map((m, i) => ({
code: m.code,
name: { zh: m.label },
sort: i + 1,
})),
meals: meals.sort((a, b) => {
if (a.dayCode !== b.dayCode) return a.dayCode - b.dayCode;
const orderMap: Record<MealTypeCode, number> = {
[MealTypeCode.BREAKFAST]: 0,
[MealTypeCode.FRUIT]: 1,
[MealTypeCode.LUNCH]: 2,
[MealTypeCode.SNACK]: 3,
[MealTypeCode.SPECIAL]: 4,
};
return orderMap[a.mealCode] - orderMap[b.mealCode];
}),
};
}
function createEmptyMenu(): WeeklyMenu {
return {
menuId: `menu-${Date.now()}`,
weekNumber: "",
title: { zh: "食谱", en: "Recipe" },
days: WEEK_DAYS.map((d) => ({ code: d.code, name: { zh: d.label } })),
mealCategories: MEAL_TYPES.map((m, i) => ({
code: m.code,
name: { zh: m.label },
sort: i + 1,
})),
meals: [],
};
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "未上传文件" }, { status: 400 });
}
if (!file.name.endsWith(".docx")) {
return NextResponse.json({ error: "仅支持 .docx 格式" }, { status: 400 });
}
// 读取文件为 ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 使用 mammoth 转换为 HTML(保留表格结构)
const { value: htmlContent } = await mammoth.convertToHtml({ buffer });
// 使用 cheerio 提取表格数据
const tableData = extractRawDataFromHtml(htmlContent);
// 转换为结构化数据
const weeklyMenu = transformTableToWeeklyMenu(tableData);
return NextResponse.json({
success: true,
data: weeklyMenu,
});
} catch (error) {
console.error("Parse error:", error);
return NextResponse.json(
{ error: "文件解析失败", details: String(error) },
{ status: 500 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { getTranslation, getTranslations } from "@/lib/database";
// 单个翻译查询
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const chineseName = searchParams.get("name");
if (!chineseName) {
return NextResponse.json({ error: "缺少 name 参数" }, { status: 400 });
}
const translation = await getTranslation(chineseName);
return NextResponse.json({
chinese: chineseName,
english: translation,
found: translation !== null,
});
}
// 批量翻译查询
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { names } = body as { names: string[] };
if (!names || !Array.isArray(names)) {
return NextResponse.json({ error: "缺少 names 参数(数组)" }, { status: 400 });
}
const translations = await getTranslations(names);
const result: Record<string, string | null> = {};
for (const name of names) {
result[name] = translations.get(name) || null;
}
return NextResponse.json({
translations: result,
total: names.length,
found: translations.size,
});
} catch (error) {
console.error("Translation API error:", error);
return NextResponse.json({ error: "请求格式错误" }, { status: 400 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import pool from "@/lib/database";
// 更新词组
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { word, english } = body;
if (!word || !english) {
return NextResponse.json(
{ error: "中文和英文翻译不能为空" },
{ status: 400 }
);
}
await pool.execute("UPDATE words SET word = ?, english = ? WHERE id = ?", [
word.trim(),
english.trim(),
parseInt(id),
]);
return NextResponse.json({
success: true,
data: {
id: parseInt(id),
word: word.trim(),
english: english.trim(),
},
});
} catch (error) {
console.error("Database error:", error);
return NextResponse.json(
{ error: "更新失败", details: String(error) },
{ status: 500 }
);
}
}
// 删除词组
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await pool.execute("DELETE FROM words WHERE id = ?", [parseInt(id)]);
return NextResponse.json({
success: true,
message: "删除成功",
});
} catch (error) {
console.error("Database error:", error);
return NextResponse.json(
{ error: "删除失败", details: String(error) },
{ status: 500 }
);
}
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import pool from "@/lib/database";
// 获取所有词组(分页)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") || "1") || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20") || 20));
const search = searchParams.get("search") || "";
try {
const offset = (page - 1) * pageSize;
// 构建查询条件
let whereClause = "";
let params: (string | number)[] = [];
if (search) {
whereClause = "WHERE word LIKE ? OR english LIKE ?";
params = [`%${search}%`, `%${search}%`];
}
// 获取总数
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM words ${whereClause}`,
params
);
const total = (countResult as any)[0]?.total || 0;
// 获取列表 - LIMIT 和 OFFSET 直接使用数字,不使用占位符
const [rows] = await pool.query(
`SELECT id, word, english, createTime, updateTime FROM words ${whereClause} ORDER BY id DESC LIMIT ${pageSize} OFFSET ${offset}`,
params
);
return NextResponse.json({
success: true,
data: rows,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize) || 1,
},
});
} catch (error) {
console.error("Database error:", error);
return NextResponse.json(
{ error: "获取数据失败", details: String(error) },
{ status: 500 }
);
}
}
// 添加词组
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { word, english } = body;
if (!word || !english) {
return NextResponse.json(
{ error: "中文和英文翻译不能为空" },
{ status: 400 }
);
}
const [result] = await pool.execute(
"INSERT INTO words (word, english) VALUES (?, ?)",
[word.trim(), english.trim()]
);
const insertResult = result as any;
return NextResponse.json({
success: true,
data: {
id: insertResult.insertId,
word: word.trim(),
english: english.trim(),
},
});
} catch (error) {
console.error("Database error:", error);
return NextResponse.json(
{ error: "添加失败", details: String(error) },
{ status: 500 }
);
}
}
// 批量添加词组
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { words } = body;
if (!Array.isArray(words) || words.length === 0) {
return NextResponse.json(
{ error: "请提供词组数组" },
{ status: 400 }
);
}
const values = words.map((w: { word: string; english: string }) => [w.word.trim(), w.english.trim()]);
const placeholders = values.map(() => "(?, ?)").join(", ");
const flatValues = values.flat();
await pool.execute(
`INSERT INTO words (word, english) VALUES ${placeholders} ON DUPLICATE KEY UPDATE english = VALUES(english)`,
flatValues
);
return NextResponse.json({
success: true,
message: `成功添加 ${words.length} 个词组`,
});
} catch (error) {
console.error("Database error:", error);
return NextResponse.json(
{ error: "批量添加失败", details: String(error) },
{ status: 500 }
);
}
}
+6 -3
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import Providers from "./providers";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
@@ -16,8 +17,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "食谱翻译工具 | 食谱解析工具,解放你牛马的双手",
description: "食谱解析工具,解放你牛马的双手",
};
export default function RootLayout({
@@ -30,7 +31,9 @@ export default function RootLayout({
lang="en"
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html>
);
}
+717 -57
View File
@@ -1,64 +1,724 @@
import Image from "next/image";
"use client";
export default function Home() {
import {useState, useCallback} from "react";
import {
Upload,
Sun,
Apple,
UtensilsCrossed,
Cookie,
Heart,
Languages,
CheckCircle2,
Loader2,
X,
AlertCircle,
Download, Settings,
} from "lucide-react";
import Link from 'next/link'
import {Button} from "@/components/ui/button";
import {
Document,
Packer,
Table,
TableRow,
TableCell,
TextRun,
Paragraph,
AlignmentType,
WidthType,
PageOrientation,
TableLayoutType
} from "docx";
// 餐食类型
enum MealTypeCode {
BREAKFAST = "breakfast",
FRUIT = "fruit",
LUNCH = "lunch",
SNACK = "snack",
SPECIAL = "special",
}
// 语言名称项
interface LangName {
zh: string;
en?: string;
}
// 单个菜品
interface Dish {
id?: string;
name: LangName;
translated?: boolean; // 是否已翻译
}
// 单日食谱
interface DailyMeal {
dayCode: number;
mealCode: MealTypeCode;
dishList: Dish[];
}
// 一周食谱
interface WeeklyMenu {
menuId?: string;
weekNumber?: string;
title: LangName;
days: { code: number; name: LangName }[];
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
meals: DailyMeal[];
}
// 餐食类型配置 (体弱儿在第四位)
const MEAL_TYPES = [
{code: MealTypeCode.BREAKFAST, label: "早餐", icon: Sun},
{code: MealTypeCode.FRUIT, label: "水果餐", icon: Apple},
{code: MealTypeCode.LUNCH, label: "中餐", icon: UtensilsCrossed},
{code: MealTypeCode.SPECIAL, label: "体弱儿餐", icon: Heart},
{code: MealTypeCode.SNACK, label: "午点", icon: Cookie},
];
type UploadState = "idle" | "uploading" | "parsing" | "done" | "error";
type TranslateState = "idle" | "translating" | "done";
/**
* 头部组件
* */
function HarderCompose() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="mx-auto max-w-6xl px-4 py-6">
<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 className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">Word · </p>
</div>
</div>
<Link href="/settings">
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4"/>
</Button>
</Link>
</div>
</div>
);
}
export default function RecipeTranslationPage() {
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [translateState, setTranslateState] = useState<TranslateState>("idle");
const [selectedMealType, setSelectedMealType] = useState<MealTypeCode | "all">("all");
const [data, setData] = useState<WeeklyMenu | null>(null);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string>("");
// 收集所有未翻译的菜品名称
const getAllDishNames = useCallback((menu: WeeklyMenu): string[] => {
const names: string[] = [];
for (const meal of menu.meals) {
for (const dish of meal.dishList) {
if (dish.name.zh && !dish.name.en) {
names.push(dish.name.zh);
}
}
}
return [...new Set(names)]; // 去重
}, []);
// 批量翻译
const handleTranslateAll = useCallback(async () => {
if (!data) return;
const dishNames = getAllDishNames(data);
if (dishNames.length === 0) {
alert("所有菜品都已翻译!");
return;
}
setTranslateState("translating");
try {
const response = await fetch("/api/translate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({names: dishNames}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "翻译失败");
}
// 更新 data 中的翻译结果
const translations = result.translations as Record<string, string | null>;
setData((prevData) => {
if (!prevData) return null;
const newMeals = prevData.meals.map((meal) => ({
...meal,
dishList: meal.dishList.map((dish) => {
const translation = dish.name.zh ? translations[dish.name.zh] : null;
if (translation) {
return {
...dish,
name: {...dish.name, en: translation},
translated: true,
};
}
return {
...dish,
translated: dish.translated || false,
};
}),
}));
return {...prevData, meals: newMeals};
});
setTranslateState("done");
} catch (err) {
console.error("Translation error:", err);
alert("翻译失败,请重试");
setTranslateState("idle");
}
}, [data, getAllDishNames]);
// 导出 Word 文档
const handleExport = useCallback(async () => {
if (!data) return;
try {
// 1. 收集所有需要翻译的内容(星期、餐食类型、菜品)
const allNames: string[] = [];
// 收集星期名称(使用实际数据中的星期)
for (const day of data.days) {
if (day.name.zh) {
allNames.push(day.name.zh);
}
}
// 收集餐食类型名称
for (const meal of MEAL_TYPES) {
allNames.push(meal.label);
}
// 收集所有菜品名称(去重)
const dishNamesSet = new Set<string>();
for (const meal of data.meals) {
for (const dish of meal.dishList) {
if (dish.name.zh) {
dishNamesSet.add(dish.name.zh);
}
}
}
allNames.push(...dishNamesSet);
// 2. 调用翻译 API
const response = await fetch("/api/translate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({names: [...new Set(allNames)]}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "翻译失败");
}
const translations = result.translations as Record<string, string | null>;
// 3. 构建翻译映射函数
const t = (zh: string): string => translations[zh] || "";
// 4. 构建表格数据
const tableRows: TableRow[] = [];
// 表头行 - 星期列头 + 餐食类型都要上中下英
const headerCells: TableCell[] = [];
const weekEnglish = translations["week"] || translations["Week"] || "Day";
headerCells.push(
new TableCell({
children: [
new Paragraph({children: [new TextRun({text: "星期 / Week", bold: true})]}),
].filter((p): p is Paragraph => p !== null) as unknown as Paragraph[],
width: {size: 12, type: WidthType.PERCENTAGE},
}),
);
for (const mealType of MEAL_TYPES) {
const zhText = mealType.label;
const enText = t(zhText);
headerCells.push(
new TableCell({
children: [
new Paragraph({children: [new TextRun({text: enText || zhText, bold: true})]}),
enText ? new Paragraph({
children: [new TextRun({
text: zhText,
color: "666666",
size: 18
})]
}) : null,
].filter((p): p is Paragraph => p !== null) as unknown as Paragraph[],
width: {size: 17.6, type: WidthType.PERCENTAGE},
})
);
}
tableRows.push(new TableRow({children: headerCells, tableHeader: true}));
// 数据行(使用实际数据中的星期)- 上中下英
for (const day of data.days) {
const dayZh = day.name.zh || "";
const dayEn = t(dayZh) || translations[dayZh] || "";
const dayCells: TableCell[] = [
new TableCell({
children: [
new Paragraph({children: [new TextRun({text: dayZh})]}),
dayEn ? new Paragraph({
children: [new TextRun({
text: dayEn,
color: "666666",
size: 18
})]
}) : null,
].filter((p): p is Paragraph => p !== null) as unknown as Paragraph[],
}),
];
for (const mealType of MEAL_TYPES) {
const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === mealType.code
);
const cellContent: Paragraph[] = [];
if (mealData?.dishList && mealData.dishList.length > 0) {
for (const dish of mealData.dishList) {
const zhText = dish.name.zh;
const enText = dish.name.en || t(zhText);
if (enText) {
cellContent.push(
new Paragraph({
children: [new TextRun({text: zhText})],
})
);
cellContent.push(
new Paragraph({
children: [new TextRun({text: enText, color: "666666", size: 18})],
})
);
} else {
cellContent.push(
new Paragraph({
children: [new TextRun({text: zhText, color: "CC0000"})],
})
);
}
}
} else {
cellContent.push(new Paragraph({children: [new TextRun({text: "—", color: "999999"})]}));
}
dayCells.push(
new TableCell({
children: cellContent,
})
);
}
tableRows.push(new TableRow({children: dayCells}));
}
// 5. 创建文档 - 横向 A4
const doc = new Document({
sections: [
{
properties: {
page: {
size: {
orientation: PageOrientation.LANDSCAPE,
width: 11906,
height: 16838,
},
margin: {
top: 700, // 约1.27厘米
bottom: 700,
left: 900, // 约1.9厘米
right: 900,
},
},
},
children: [
new Paragraph({
children: [
new TextRun({
text: t(data.title?.zh || "食谱") || data.title?.zh || "食谱",
bold: true,
size: 32,
}),
],
alignment: AlignmentType.CENTER,
}),
new Paragraph({children: []}),
new Table({
rows: tableRows,
width: {size: 100, type: WidthType.PERCENTAGE},
layout: TableLayoutType.FIXED,
}),
],
},
],
});
// 6. 生成并下载
const blob = await Packer.toBlob(doc);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName || data.title?.zh || "食谱翻译"}.docx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export error:", err);
alert("导出失败,请重试");
}
}, [data, fileName]);
const handleFileUpload = useCallback(
async (file: File) => {
if (!file.name.endsWith(".docx")) {
setError("请上传 .docx 格式的文件");
setUploadState("error");
return;
}
setUploadState("uploading");
setError(null);
setTranslateState("idle");
try {
const formData = new FormData();
formData.append("file", file);
setUploadState("parsing");
const response = await fetch("/api/recipe", {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "解析失败");
}
setData(result.data);
setFileName(file.name.replace(/\.docx$/i, ""));
setUploadState("done");
} catch (err) {
setError(err instanceof Error ? err.message : "文件解析失败,请检查格式");
setUploadState("error");
}
},
[]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
},
[handleFileUpload]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
},
[handleFileUpload]
);
const reset = () => {
setUploadState("idle");
setData(null);
setError(null);
setTranslateState("idle");
setSelectedMealType("all");
setFileName("");
};
// 统计翻译状态
const untranslatedCount = data ? getAllDishNames(data).length : 0;
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card">
<HarderCompose/>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Upload Zone - 闲置状态 */}
{uploadState === "idle" && (
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className="group relative flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-muted/50 p-12 transition-all hover:border-primary/50 hover:bg-muted"
>
<input
type="file"
accept=".docx"
onChange={handleFileInput}
className="absolute inset-0 cursor-pointer opacity-0"
/>
<div
className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary transition-transform group-hover:scale-110">
<Upload className="h-8 w-8"/>
</div>
<p className="mb-1 text-lg font-medium"> Word </p>
<p className="text-sm text-muted-foreground"> .docx </p>
</div>
)}
{/* 上传中 */}
{uploadState === "uploading" && (
<div className="flex flex-col items-center justify-center rounded-2xl border bg-card p-12">
<Loader2 className="mb-4 h-12 w-12 animate-spin text-primary"/>
<p className="text-lg font-medium">...</p>
</div>
)}
{/* 解析中 */}
{uploadState === "parsing" && (
<div className="flex flex-col items-center justify-center rounded-2xl border bg-card p-12">
<Loader2 className="mb-4 h-12 w-12 animate-spin text-primary"/>
<p className="text-lg font-medium">...</p>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
)}
{/* 错误状态 */}
{uploadState === "error" && (
<div
className="flex flex-col items-center justify-center rounded-2xl border border-destructive/50 bg-destructive/5 p-12">
<div
className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<X className="h-8 w-8"/>
</div>
<p className="mb-1 text-lg font-medium text-destructive">{error}</p>
<Button variant="outline" onClick={reset} className="mt-4">
</Button>
</div>
)}
{/* 完成 - 显示结果 */}
{uploadState === "done" && data && (
<div className="space-y-6">
{/* 成功提示 + 重新上传 */}
<div className="flex items-center justify-between rounded-xl border bg-card p-4">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-primary"/>
<span className="font-medium"></span>
<span className="text-sm text-muted-foreground">{data.title.zh}</span>
{untranslatedCount > 0 && translateState !== "translating" && (
<span className="flex items-center gap-1 text-sm text-destructive">
<AlertCircle className="h-4 w-4"/>
{untranslatedCount}
</span>
)}
{translateState === "done" && untranslatedCount === 0 && (
<span className="flex items-center gap-1 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4"/>
</span>
)}
</div>
<Button variant="outline" size="sm" onClick={reset}>
</Button>
</div>
{/* 餐食类型筛选 + 操作按钮 */}
<div className="flex flex-wrap items-center justify-between gap-4">
{/* 餐食类型筛选 */}
<div className="flex flex-wrap gap-2">
<Button
variant={selectedMealType === "all" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedMealType("all")}
>
</Button>
{MEAL_TYPES.map((meal) => {
const Icon = meal.icon;
return (
<Button
key={meal.code}
variant={selectedMealType === meal.code ? "default" : "outline"}
size="sm"
onClick={() => setSelectedMealType(meal.code)}
className="gap-1.5"
>
<Icon className="h-4 w-4"/>
{meal.label}
</Button>
);
})}
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
variant="outline"
className="gap-2"
onClick={handleExport}
disabled={translateState === "translating"}
>
<Download className="h-4 w-4"/>
</Button>
<Button
className="gap-2"
onClick={handleTranslateAll}
disabled={translateState === "translating" || untranslatedCount === 0}
>
{translateState === "translating" ? (
<>
<Loader2 className="h-4 w-4 animate-spin"/>
...
</>
) : (
<>
<Languages className="h-4 w-4"/>
{untranslatedCount > 0 &&
<span className="text-xs opacity-70">({untranslatedCount})</span>}
</>
)}
</Button>
</div>
</div>
{/* 食谱表格 */}
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="min-w-[80px] px-4 py-3 text-left font-medium"></th>
{selectedMealType === "all"
? MEAL_TYPES.map((meal) => {
const Icon = meal.icon;
return (
<th key={meal.code} className="px-4 py-3 text-center font-medium">
<div className="flex items-center justify-center gap-1.5">
<Icon className="h-4 w-4"/>
{meal.label}
</div>
</th>
);
})
: MEAL_TYPES.find((m) => m.code === selectedMealType)?.icon && (
<th className="px-4 py-3 text-center font-medium">
{
MEAL_TYPES.find((m) => m.code === selectedMealType)
?.label
}
</th>
)}
</tr>
</thead>
<tbody>
{data.days.map((day) => (
<tr key={day.code} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{day.name.zh}</td>
{selectedMealType === "all" ? (
MEAL_TYPES.map((meal) => {
const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === meal.code
);
return (
<td key={meal.code} className="px-4 py-3">
<div className="flex flex-col gap-1">
{mealData?.dishList.length ? (
mealData.dishList.map((dish) => (
<div
key={dish.id}
className="flex flex-col gap-0.5 text-sm"
>
<span className={dish.name.en ? "text-foreground" : "text-destructive"}>
{dish.name.zh}
</span>
{dish.name.en && (
<span
className="text-xs text-muted-foreground">
{dish.name.en}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</td>
);
})
) : (
<td className="px-4 py-3">
{(() => {
const mealData = data.meals.find(
(m) =>
m.dayCode === day.code &&
m.mealCode === selectedMealType
);
return mealData?.dishList.length ? (
<div className="flex flex-col gap-1">
{mealData.dishList.map((dish) => (
<div
key={dish.id}
className="flex flex-col gap-0.5"
>
<span className={dish.name.en ? "text-foreground" : "text-destructive"}>
{dish.name.zh}
</span>
{dish.name.en && (
<span className="text-xs text-muted-foreground">
{dish.name.en}
</span>
)}
</div>
))}
</div>
) : (
<span className="text-muted-foreground"></span>
);
})()}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
);
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { ToastProvider } from "@/components/ui/toast";
export default function Providers({ children }: { children: React.ReactNode }) {
return <ToastProvider>{children}</ToastProvider>;
}
+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>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2.5 right-3", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import { X } from "lucide-react";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
interface Toast {
id: string;
type: "success" | "error" | "info";
title: string;
description?: string;
}
interface ToastContextType {
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextType | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { ...toast, id }]);
// Auto remove after 4 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
{/* Toast Container */}
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<Alert key={toast.id} variant={toast.type === "error" ? "destructive" : "default"}>
<AlertTitle>{toast.title}</AlertTitle>
{toast.description && (
<AlertDescription>{toast.description}</AlertDescription>
)}
<div className="absolute top-2.5 right-3 flex items-center gap-1">
<button
onClick={() => removeToast(toast.id)}
className="rounded-sm p-1 hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
</Alert>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
+14
View File
@@ -0,0 +1,14 @@
{
"db": {
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"password": "",
"database": "recipe_tools"
},
"ai": {
"apiKey": "6a81b361620a44fd86aa3c8fcd833800.PjPyiU8kSzCTviYa",
"baseUrl": "https://open.bigmodel.cn/api/paas/v4/",
"model": "GLM-4.7-Flash"
}
}
+9
View File
@@ -0,0 +1,9 @@
# 数据库配置
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=123456
DB_NAME=recipe_tools
# 服务器配置
PORT=3000
+139
View File
@@ -0,0 +1,139 @@
import OpenAI from "openai";
// AI 翻译配置
interface AIConfig {
apiKey: string;
baseUrl?: string;
model?: string;
}
// 默认配置(从环境变量)
function getDefaultConfig(): AIConfig {
return {
apiKey: process.env.AI_API_KEY || "",
baseUrl: process.env.AI_BASE_URL || undefined,
model: process.env.AI_MODEL || "gpt-3.5-turbo",
};
}
// 模块级配置存储
let savedConfig: AIConfig | null = null;
// 设置保存的配置
export function setAIConfig(config: AIConfig) {
savedConfig = config;
}
// 获取配置(优先使用保存的配置)
export function getAIConfig(): AIConfig {
return savedConfig || getDefaultConfig();
}
// 解析翻译结果
function parseTranslation(content: string): string | null {
const patterns = [
/english\s*:\s*['"]([^'"]+)['"]/i,
/english\s*[:\s]+([^,'"\n]+)/i,
/['"]([^'"]+)['"]\s*[,}].*english/i,
/^([^,]+)\s*,\s*english\s*:\s*['"]([^'"]+)['"]/i,
];
for (const pattern of patterns) {
const match = content.match(pattern);
if (match) {
return match[1]?.trim() || null;
}
}
return null;
}
// 翻译单个词组(带重试)
export async function translateWithAI(
word: string,
config?: AIConfig,
maxRetries: number = 3
): Promise<string | null> {
const aiConfig = config || getAIConfig();
if (!aiConfig.apiKey) {
throw new Error("AI API Key 未配置");
}
const client = new OpenAI({
apiKey: aiConfig.apiKey,
baseURL: aiConfig.baseUrl,
});
const prompt = `你是一个专业的中文到英文翻译助手。
任务:将给定的中文词组翻译成英文。
严格要求:
1. 必须严格返回 JSON 格式:{"word":"中文原文","english":"英文翻译"}
2. 不要返回任何其他内容,不要解释
3. 只返回这一行 JSON
4. 翻译要简洁、准确
中文词组:${word}
请立即返回 JSON 结果:`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const completion = await client.chat.completions.create({
model: aiConfig.model || "gpt-3.5-turbo",
messages: [
{
role: "system",
content: "你是一个严格遵循指令的翻译助手。必须只返回要求的 JSON 格式,不要返回任何其他内容。",
},
{ role: "user", content: prompt },
],
temperature: 0,
max_tokens: 50,
});
const content = completion.choices[0]?.message?.content || "";
const translation = parseTranslation(content);
if (translation && translation.length > 0) {
return translation;
}
// Fallback: 提取英文单词
const fallbackMatch = content.match(/[a-zA-Z][a-zA-Z\s-]*/);
if (fallbackMatch) {
const fallback = fallbackMatch[0].trim();
if (fallback.length > 1) {
return fallback;
}
}
} catch (error) {
console.error(`Attempt ${attempt + 1} error:`, error);
}
}
return null;
}
// 批量翻译词组
export async function batchTranslateWithAI(
words: string[],
config?: AIConfig
): Promise<Map<string, string>> {
const results = new Map<string, string>();
for (const word of words) {
try {
const translation = await translateWithAI(word, config);
if (translation) {
results.set(word, translation);
}
} catch (error) {
console.error(`Failed to translate "${word}":`, error);
}
}
return results;
}
+173
View File
@@ -0,0 +1,173 @@
import fs from "fs";
import path from "path";
export interface DBConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
}
export interface AIConfig {
apiKey: string;
baseUrl: string;
model: string;
}
export interface AppConfig {
db: DBConfig;
ai: AIConfig;
}
// 配置文件路径(单一文件)
const configPath = path.join(process.cwd(), "config.json");
/**
* 读取完整配置
*/
function readConfigFile(): Partial<AppConfig> {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
return JSON.parse(content);
}
} catch (e) {
console.error("Config read error:", e);
}
return {};
}
/**
* 写入完整配置
*/
function writeConfigFile(config: AppConfig): void {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
}
/**
* 获取数据库配置
* 优先级:环境变量 > config.json
*/
export function getDBConfig(): DBConfig {
let host = "127.0.0.1";
let port = 3306;
let user = "root";
let password = "";
let database = "recipe_tools";
// 环境变量优先级最高
if (process.env.DB_HOST) host = process.env.DB_HOST;
if (process.env.DB_PORT) port = parseInt(process.env.DB_PORT);
if (process.env.DB_USER) user = process.env.DB_USER;
if (process.env.DB_PASSWORD) password = process.env.DB_PASSWORD;
if (process.env.DB_NAME) database = process.env.DB_NAME;
// config.json 次之(仅当环境变量未设置时)
const fileConfig = readConfigFile();
if (fileConfig.db) {
if (fileConfig.db.host && !process.env.DB_HOST) host = fileConfig.db.host;
if (fileConfig.db.port && !process.env.DB_PORT) port = fileConfig.db.port;
if (fileConfig.db.user && !process.env.DB_USER) user = fileConfig.db.user;
if (fileConfig.db.password && !process.env.DB_PASSWORD) password = fileConfig.db.password;
if (fileConfig.db.database && !process.env.DB_NAME) database = fileConfig.db.database;
}
return { host, port, user, password, database };
}
/**
* 获取AI翻译配置
* 优先级:环境变量 > config.json
*/
export function getAIConfig(): AIConfig {
let apiKey = "";
let baseUrl = "https://api.openai.com/v1";
let model = "gpt-3.5-turbo";
// 环境变量优先级最高
if (process.env.AI_API_KEY) apiKey = process.env.AI_API_KEY;
if (process.env.AI_BASE_URL) baseUrl = process.env.AI_BASE_URL;
if (process.env.AI_MODEL) model = process.env.AI_MODEL;
// config.json 次之(仅当环境变量未设置时)
const fileConfig = readConfigFile();
if (fileConfig.ai) {
if (fileConfig.ai.apiKey && !process.env.AI_API_KEY) apiKey = fileConfig.ai.apiKey;
if (fileConfig.ai.baseUrl && !process.env.AI_BASE_URL) baseUrl = fileConfig.ai.baseUrl;
if (fileConfig.ai.model && !process.env.AI_MODEL) model = fileConfig.ai.model;
}
return { apiKey, baseUrl, model };
}
/**
* 获取完整配置(兼容旧接口)
*/
export function getConfig(): AppConfig {
return {
db: getDBConfig(),
ai: getAIConfig(),
};
}
/**
* 保存数据库配置
*/
export function saveDBConfig(config: Partial<DBConfig>): DBConfig {
const current = getDBConfig();
const newConfig: DBConfig = {
host: config.host ?? current.host,
port: config.port ?? current.port,
user: config.user ?? current.user,
password: config.password ?? current.password,
database: config.database ?? current.database,
};
// 读取现有配置,更新 db 部分
const fileConfig = readConfigFile();
const updatedConfig: AppConfig = {
ai: fileConfig.ai ?? { apiKey: "", baseUrl: "https://api.openai.com/v1", model: "gpt-3.5-turbo" },
db: newConfig,
};
writeConfigFile(updatedConfig);
return newConfig;
}
/**
* 保存AI翻译配置
*/
export function saveAIConfig(config: Partial<AIConfig>): AIConfig {
const current = getAIConfig();
const newConfig: AIConfig = {
apiKey: config.apiKey ?? current.apiKey,
baseUrl: config.baseUrl ?? current.baseUrl,
model: config.model ?? current.model,
};
// 读取现有配置,更新 ai 部分
const fileConfig = readConfigFile();
const updatedConfig: AppConfig = {
db: fileConfig.db ?? { host: "127.0.0.1", port: 3306, user: "root", password: "", database: "recipe_tools" },
ai: newConfig,
};
writeConfigFile(updatedConfig);
return newConfig;
}
/**
* 保存完整配置(兼容旧接口)
*/
export function saveConfig(config: Partial<AppConfig>): AppConfig {
const current = getConfig();
const fileConfig = readConfigFile();
return {
db: config.db ? saveDBConfig(config.db) : current.db,
ai: config.ai ? saveAIConfig(config.ai) : current.ai,
};
}
+67
View File
@@ -0,0 +1,67 @@
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: process.env.DB_HOST || "127.0.0.1",
port: parseInt(process.env.DB_PORT || "3306"),
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "",
database: process.env.DB_NAME || "recipe_tools",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export default pool;
/**
* 根据中文菜名查询英文翻译
* @param chineseName 中文菜名
* @returns 翻译结果或 null
*/
export async function getTranslation(chineseName: string): Promise<string | null> {
try {
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
"SELECT english FROM words WHERE word = ? LIMIT 1",
[chineseName]
);
if (rows.length > 0 && rows[0].english) {
return rows[0].english as string;
}
return null;
} catch (error) {
console.error("Database query error:", error);
return null;
}
}
/**
* 批量查询翻译
* @param chineseNames 中文菜名数组
* @returns 翻译结果映射 { chineseName: englishName }
*/
export async function getTranslations(
chineseNames: string[]
): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (chineseNames.length === 0) return result;
try {
const placeholders = chineseNames.map(() => "?").join(",");
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
`SELECT word, english FROM words WHERE word IN (${placeholders})`,
chineseNames
);
for (const row of rows) {
if (row.word && row.english) {
result.set(row.word as string, row.english as string);
}
}
} catch (error) {
console.error("Database batch query error:", error);
}
return result;
}
+6
View File
@@ -9,10 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"adm-zip": "^0.5.17",
"cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"docx": "^9.6.1",
"lucide-react": "^1.8.0",
"mammoth": "^1.12.0",
"mysql2": "^3.22.0",
"next": "16.2.3",
"openai": "^6.34.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
+521 -4
View File
File diff suppressed because it is too large Load Diff