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 { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import Providers from "./providers";
|
||||||
|
|
||||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "食谱翻译工具 | 食谱解析工具,解放你牛马的双手",
|
||||||
description: "Generated by create next app",
|
description: "食谱解析工具,解放你牛马的双手",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -30,7 +31,9 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+723
-63
@@ -1,65 +1,725 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
|
|
||||||
export default function Home() {
|
import {useState, useCallback} from "react";
|
||||||
return (
|
import {
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
Upload,
|
||||||
<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">
|
Sun,
|
||||||
<Image
|
Apple,
|
||||||
className="dark:invert"
|
UtensilsCrossed,
|
||||||
src="/next.svg"
|
Cookie,
|
||||||
alt="Next.js logo"
|
Heart,
|
||||||
width={100}
|
Languages,
|
||||||
height={20}
|
CheckCircle2,
|
||||||
priority
|
Loader2,
|
||||||
/>
|
X,
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
AlertCircle,
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
Download, Settings,
|
||||||
To get started, edit the page.tsx file.
|
} from "lucide-react";
|
||||||
</h1>
|
import Link from 'next/link'
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
import {Button} from "@/components/ui/button";
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
import {
|
||||||
<a
|
Document,
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
Packer,
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
Table,
|
||||||
>
|
TableRow,
|
||||||
Templates
|
TableCell,
|
||||||
</a>{" "}
|
TextRun,
|
||||||
or the{" "}
|
Paragraph,
|
||||||
<a
|
AlignmentType,
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
WidthType,
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
PageOrientation,
|
||||||
>
|
TableLayoutType
|
||||||
Learning
|
} from "docx";
|
||||||
</a>{" "}
|
|
||||||
center.
|
// 餐食类型
|
||||||
</p>
|
enum MealTypeCode {
|
||||||
</div>
|
BREAKFAST = "breakfast",
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
FRUIT = "fruit",
|
||||||
<a
|
LUNCH = "lunch",
|
||||||
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]"
|
SNACK = "snack",
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
SPECIAL = "special",
|
||||||
target="_blank"
|
}
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
// 语言名称项
|
||||||
<Image
|
interface LangName {
|
||||||
className="dark:invert"
|
zh: string;
|
||||||
src="/vercel.svg"
|
en?: string;
|
||||||
alt="Vercel logomark"
|
}
|
||||||
width={16}
|
|
||||||
height={16}
|
// 单个菜品
|
||||||
/>
|
interface Dish {
|
||||||
Deploy Now
|
id?: string;
|
||||||
</a>
|
name: LangName;
|
||||||
<a
|
translated?: boolean; // 是否已翻译
|
||||||
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"
|
interface DailyMeal {
|
||||||
>
|
dayCode: number;
|
||||||
Documentation
|
mealCode: MealTypeCode;
|
||||||
</a>
|
dishList: Dish[];
|
||||||
</div>
|
}
|
||||||
</main>
|
|
||||||
</div>
|
// 一周食谱
|
||||||
);
|
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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"docx": "^9.6.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
|
"mysql2": "^3.22.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
|
"openai": "^6.34.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "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