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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+723
-63
@@ -1,65 +1,725 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
export default function Home() {
|
||||
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>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
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="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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ToastProvider } from "@/components/ui/toast";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <ToastProvider>{children}</ToastProvider>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+521
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user