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