diff --git a/app/api/recipe/route.ts b/app/api/recipe/route.ts index 54af381..87302c8 100644 --- a/app/api/recipe/route.ts +++ b/app/api/recipe/route.ts @@ -4,103 +4,103 @@ import * as cheerio from "cheerio"; // 餐食类型 enum MealTypeCode { - BREAKFAST = "breakfast", - FRUIT = "fruit", - LUNCH = "lunch", - SNACK = "snack", - SPECIAL = "special", + BREAKFAST = "breakfast", + FRUIT = "fruit", + LUNCH = "lunch", + SNACK = "snack", + SPECIAL = "special", } // 语言名称项 interface LangName { - zh: string; - en?: string; + zh: string; + en?: string; } // 单个菜品 interface Dish { - id: string; - name: LangName; + id: string; + name: LangName; } // 单日食谱 interface DailyMeal { - dayCode: number; - mealCode: MealTypeCode; - dishList: Dish[]; + 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[]; + 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: "午点" }, + {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: "周五" }, + {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 tableData: string[][][] = []; + const $ = cheerio.load(html); - const rows = $("table tr"); + const rows = $("table tr"); - rows.each((rowIndex, row) => { - const rowData: string[][] = []; - const cells = $(row).find("td"); + rows.each((rowIndex, row) => { + const rowData: string[][] = []; + const cells = $(row).find("td"); - cells.each((cellIndex, cell) => { - // 直接获取 HTML 并处理 - const rawHtml = $(cell).html() || ""; + cells.each((cellIndex, cell) => { + // 直接获取 HTML 并处理 + const rawHtml = $(cell).html() || ""; - // 替换换行标签为真实换行符 - const processed = rawHtml - .replace(/<\/p>]*>/g, "\n") //

→ 换行 - .replace(//gi, "\n") - .replace(/ /g, " "); + // 替换换行标签为真实换行符 + const processed = rawHtml + .replace(/<\/p>]*>/g, "\n") //

→ 换行 + .replace(//gi, "\n") + .replace(/ /g, " "); - // 移除剩余 HTML 标签 - const text = processed.replace(/<[^>]+>/g, ""); + // 移除剩余 HTML 标签 + const text = processed.replace(/<[^>]+>/g, ""); - // 按换行分割 - const lines = text.split("\n").filter((line) => line.trim() !== ""); + // 按换行分割 + const lines = text.split("\n").filter((line) => line.trim() !== ""); - // 对每行应用顿号/逗号/分号分割 - const finalItems: string[] = []; - lines.forEach((line) => { - finalItems.push(...splitCellContent(line)); - }); + // 对每行应用顿号/逗号/分号分割 + const finalItems: string[] = []; + lines.forEach((line) => { + finalItems.push(...splitCellContent(line)); + }); - rowData.push(finalItems); + rowData.push(finalItems); + }); + + if (rowData.length > 0) { + tableData.push(rowData); + } }); - if (rowData.length > 0) { - tableData.push(rowData); - } - }); - - return tableData; + return tableData; } /** @@ -108,173 +108,176 @@ function extractRawDataFromHtml(html: string): string[][][] { * 支持分隔符:顿号(、)、逗号(,)、分号(;) */ function splitCellContent(content: string): string[] { - if (!content || content.trim() === "") return []; + if (!content || content.trim() === "") return []; - // 统一分隔符 - const normalized = content - .replace(/;/g, "|") - .replace(/,/g, "|") - .replace(/、/g, "|") - .trim(); + // 统一分隔符 + const normalized = content + .replace(/;/g, "|") + .replace(/,/g, "|") + .replace(/、/g, "|") + .trim(); - const parts = normalized.split("|"); + const parts = normalized.split("|"); - return parts - .map((item) => item.trim()) - .filter(Boolean); + return parts + .map((item) => item.trim()) + .filter(Boolean); } /** * 确定餐食类型 */ function getMealType(mealName: string): MealTypeCode | null { - if (mealName.includes("早") && !mealName.includes("早点")) return MealTypeCode.BREAKFAST; - if (mealName.includes("水果") || 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; + 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[] = []; + const meals: DailyMeal[] = []; - // 校验表格数据完整性 - if (tableData.length < 2 || tableData[0].length < 2) { - console.warn("警告:表格数据不完整"); - return createEmptyMenu(); - } + // 校验表格数据完整性 + if (tableData.length < 2 || tableData[0].length < 2) { + console.warn("警告:表格数据不完整"); + return createEmptyMenu(); + } - // 提取表头数据(星期几) - const weekdays = tableData[0].slice(1).map((cell) => cell[0] || ""); + // 提取表头数据(星期几) + const weekdays = tableData[0].slice(1).map((cell) => cell[0] || ""); - // 提取餐型行数据(从第二行开始) - const mealTypeRows = tableData.slice(1); + // 提取餐型行数据(从第二行开始) + const mealTypeRows = tableData.slice(1); - // 遍历每一天(列) - for (let dayIndex = 0; dayIndex < weekdays.length; dayIndex++) { - const dayName = weekdays[dayIndex]; - if (!dayName) continue; + // 遍历每一天(列) + for (let dayIndex = 0; dayIndex < weekdays.length; dayIndex++) { + const dayName = weekdays[dayIndex]; + if (!dayName) continue; - const columnIndex = dayIndex + 1; // 对应星期几的列索引 + const columnIndex = dayIndex + 1; // 对应星期几的列索引 - // 遍历每一行餐型 - for (const row of mealTypeRows) { - const mealName = row[0]?.[0] || ""; // 餐型名称 - if (!mealName) continue; + // 遍历每一行餐型 + for (const row of mealTypeRows) { + const mealName = row[0]?.[0] || ""; // 餐型名称 + if (!mealName) continue; - const mealType = getMealType(mealName); - if (!mealType) continue; + const mealType = getMealType(mealName); + if (!mealType) continue; - // 获取当前餐型的菜品列表 - const mealItems = row[columnIndex] || []; + // 获取当前餐型的菜品列表 + 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 }, + // 添加到当天餐食列表 + 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, })), - }); - } - } - - // 确保每天每餐都有记录 - 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.BREAKFAST]: 0, - [MealTypeCode.FRUIT]: 1, - [MealTypeCode.LUNCH]: 2, - [MealTypeCode.SPECIAL]: 3, - [MealTypeCode.SNACK]: 4, - }; - return orderMap[a.mealCode] - orderMap[b.mealCode]; - }), - }; + meals: meals.sort((a, b) => { + if (a.dayCode !== b.dayCode) return a.dayCode - b.dayCode; + const orderMap: Record = { + [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: [], - }; + 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; + try { + const formData = await request.formData(); + const file = formData.get("file") as File | null; - if (!file) { - return NextResponse.json({ error: "未上传文件" }, { status: 400 }); + 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} + ); } - - 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 } - ); - } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 581a9d5..e94965b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +allowBuilds: + msw: true + sharp: true + unrs-resolver: true ignoredBuiltDependencies: - sharp - unrs-resolver