Files
recipe_tool/app/api/recipe/route.ts
T

284 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {NextRequest, NextResponse} from "next/server";
import mammoth from "mammoth";
import * as cheerio from "cheerio";
// 餐食类型
enum MealTypeCode {
BREAKFAST = "breakfast",
FRUIT = "fruit",
LUNCH = "lunch",
SNACK = "snack",
SPECIAL = "special",
}
// 语言名称项
interface LangName {
zh: string;
en?: string;
}
// 单个菜品
interface Dish {
id: string;
name: LangName;
}
// 单日食谱
interface DailyMeal {
dayCode: number;
mealCode: MealTypeCode;
dishList: Dish[];
}
// 一周食谱
interface WeeklyMenu {
menuId: string;
weekNumber: string;
title: LangName;
days: { code: number; name: LangName }[];
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
meals: DailyMeal[];
}
// 餐食类型配置
const MEAL_TYPES = [
{code: MealTypeCode.BREAKFAST, label: "早餐"},
{code: MealTypeCode.FRUIT, label: "早点/水果餐"},
{code: MealTypeCode.LUNCH, label: "中餐"},
{code: MealTypeCode.SPECIAL, label: "体弱儿餐"},
{code: MealTypeCode.SNACK, label: "午点"},
];
const WEEK_DAYS = [
{code: 1, label: "周一"},
{code: 2, label: "周二"},
{code: 3, label: "周三"},
{code: 4, label: "周四"},
{code: 5, label: "周五"},
];
/**
* 使用 Cheerio 从 HTML 中提取表格原始数据
*/
function extractRawDataFromHtml(html: string): string[][][] {
const tableData: string[][][] = [];
const $ = cheerio.load(html);
const rows = $("table tr");
rows.each((rowIndex, row) => {
const rowData: string[][] = [];
const cells = $(row).find("td");
cells.each((cellIndex, cell) => {
// 直接获取 HTML 并处理
const rawHtml = $(cell).html() || "";
// 替换换行标签为真实换行符
const processed = rawHtml
.replace(/<\/p><p[^>]*>/g, "\n") // </p><p> → 换行
.replace(/<br\s*\/?>/gi, "\n")
.replace(/&nbsp;/g, " ");
// 移除剩余 HTML 标签
const text = processed.replace(/<[^>]+>/g, "");
// 按换行分割
const lines = text.split("\n").filter((line) => line.trim() !== "");
// 对每行应用顿号/逗号/分号分割
const finalItems: string[] = [];
lines.forEach((line) => {
finalItems.push(...splitCellContent(line));
});
rowData.push(finalItems);
});
if (rowData.length > 0) {
tableData.push(rowData);
}
});
return tableData;
}
/**
* 分割单元格内容为菜品列表
* 支持分隔符:顿号(、)、逗号(,)、分号(;)
*/
function splitCellContent(content: string): string[] {
if (!content || content.trim() === "") return [];
// 统一分隔符
const normalized = content
.replace(//g, "|")
.replace(/,/g, "|")
.replace(/、/g, "|")
.trim();
const parts = normalized.split("|");
return parts
.map((item) => item.trim())
.filter(Boolean);
}
/**
* 确定餐食类型
*/
function getMealType(mealName: string): MealTypeCode | null {
const header = mealName.trim();
if (header.includes("早餐") || header.includes("早 餐")) return MealTypeCode.BREAKFAST;
if (header.includes("早点") || header.includes("水果餐") || header.includes("早 点") || header.includes("早点/水果餐")) return MealTypeCode.FRUIT;
if (header.includes("中餐") || header.includes("中 餐")) return MealTypeCode.LUNCH;
if (header.includes("午点") || header.includes("午 点")) return MealTypeCode.SNACK;
if (header.includes("体弱儿餐") || header.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.SPECIAL]: 3,
[MealTypeCode.SNACK]: 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}
);
}
}