Compare commits
1 Commits
bc83044afb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ba61d5bb |
+197
-194
@@ -4,103 +4,103 @@ import * as cheerio from "cheerio";
|
|||||||
|
|
||||||
// 餐食类型
|
// 餐食类型
|
||||||
enum MealTypeCode {
|
enum MealTypeCode {
|
||||||
BREAKFAST = "breakfast",
|
BREAKFAST = "breakfast",
|
||||||
FRUIT = "fruit",
|
FRUIT = "fruit",
|
||||||
LUNCH = "lunch",
|
LUNCH = "lunch",
|
||||||
SNACK = "snack",
|
SNACK = "snack",
|
||||||
SPECIAL = "special",
|
SPECIAL = "special",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言名称项
|
// 语言名称项
|
||||||
interface LangName {
|
interface LangName {
|
||||||
zh: string;
|
zh: string;
|
||||||
en?: string;
|
en?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单个菜品
|
// 单个菜品
|
||||||
interface Dish {
|
interface Dish {
|
||||||
id: string;
|
id: string;
|
||||||
name: LangName;
|
name: LangName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单日食谱
|
// 单日食谱
|
||||||
interface DailyMeal {
|
interface DailyMeal {
|
||||||
dayCode: number;
|
dayCode: number;
|
||||||
mealCode: MealTypeCode;
|
mealCode: MealTypeCode;
|
||||||
dishList: Dish[];
|
dishList: Dish[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一周食谱
|
// 一周食谱
|
||||||
interface WeeklyMenu {
|
interface WeeklyMenu {
|
||||||
menuId: string;
|
menuId: string;
|
||||||
weekNumber: string;
|
weekNumber: string;
|
||||||
title: LangName;
|
title: LangName;
|
||||||
days: { code: number; name: LangName }[];
|
days: { code: number; name: LangName }[];
|
||||||
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
|
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
|
||||||
meals: DailyMeal[];
|
meals: DailyMeal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 餐食类型配置
|
// 餐食类型配置
|
||||||
const MEAL_TYPES = [
|
const MEAL_TYPES = [
|
||||||
{ code: MealTypeCode.BREAKFAST, label: "早餐" },
|
{code: MealTypeCode.BREAKFAST, label: "早餐"},
|
||||||
{ code: MealTypeCode.FRUIT, label: "早点/水果餐" },
|
{code: MealTypeCode.FRUIT, label: "早点/水果餐"},
|
||||||
{ code: MealTypeCode.LUNCH, label: "中餐" },
|
{code: MealTypeCode.LUNCH, label: "中餐"},
|
||||||
{ code: MealTypeCode.SPECIAL, label: "体弱儿餐" },
|
{code: MealTypeCode.SPECIAL, label: "体弱儿餐"},
|
||||||
{ code: MealTypeCode.SNACK, label: "午点" },
|
{code: MealTypeCode.SNACK, label: "午点"},
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEEK_DAYS = [
|
const WEEK_DAYS = [
|
||||||
{ code: 1, label: "周一" },
|
{code: 1, label: "周一"},
|
||||||
{ code: 2, label: "周二" },
|
{code: 2, label: "周二"},
|
||||||
{ code: 3, label: "周三" },
|
{code: 3, label: "周三"},
|
||||||
{ code: 4, label: "周四" },
|
{code: 4, label: "周四"},
|
||||||
{ code: 5, label: "周五" },
|
{code: 5, label: "周五"},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 Cheerio 从 HTML 中提取表格原始数据
|
* 使用 Cheerio 从 HTML 中提取表格原始数据
|
||||||
*/
|
*/
|
||||||
function extractRawDataFromHtml(html: string): string[][][] {
|
function extractRawDataFromHtml(html: string): string[][][] {
|
||||||
const tableData: string[][][] = [];
|
const tableData: string[][][] = [];
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const rows = $("table tr");
|
const rows = $("table tr");
|
||||||
|
|
||||||
rows.each((rowIndex, row) => {
|
rows.each((rowIndex, row) => {
|
||||||
const rowData: string[][] = [];
|
const rowData: string[][] = [];
|
||||||
const cells = $(row).find("td");
|
const cells = $(row).find("td");
|
||||||
|
|
||||||
cells.each((cellIndex, cell) => {
|
cells.each((cellIndex, cell) => {
|
||||||
// 直接获取 HTML 并处理
|
// 直接获取 HTML 并处理
|
||||||
const rawHtml = $(cell).html() || "";
|
const rawHtml = $(cell).html() || "";
|
||||||
|
|
||||||
// 替换换行标签为真实换行符
|
// 替换换行标签为真实换行符
|
||||||
const processed = rawHtml
|
const processed = rawHtml
|
||||||
.replace(/<\/p><p[^>]*>/g, "\n") // </p><p> → 换行
|
.replace(/<\/p><p[^>]*>/g, "\n") // </p><p> → 换行
|
||||||
.replace(/<br\s*\/?>/gi, "\n")
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
.replace(/ /g, " ");
|
.replace(/ /g, " ");
|
||||||
|
|
||||||
// 移除剩余 HTML 标签
|
// 移除剩余 HTML 标签
|
||||||
const text = processed.replace(/<[^>]+>/g, "");
|
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[] = [];
|
const finalItems: string[] = [];
|
||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
finalItems.push(...splitCellContent(line));
|
finalItems.push(...splitCellContent(line));
|
||||||
});
|
});
|
||||||
|
|
||||||
rowData.push(finalItems);
|
rowData.push(finalItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rowData.length > 0) {
|
||||||
|
tableData.push(rowData);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rowData.length > 0) {
|
return tableData;
|
||||||
tableData.push(rowData);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return tableData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,173 +108,176 @@ function extractRawDataFromHtml(html: string): string[][][] {
|
|||||||
* 支持分隔符:顿号(、)、逗号(,)、分号(;)
|
* 支持分隔符:顿号(、)、逗号(,)、分号(;)
|
||||||
*/
|
*/
|
||||||
function splitCellContent(content: string): string[] {
|
function splitCellContent(content: string): string[] {
|
||||||
if (!content || content.trim() === "") return [];
|
if (!content || content.trim() === "") return [];
|
||||||
|
|
||||||
// 统一分隔符
|
// 统一分隔符
|
||||||
const normalized = content
|
const normalized = content
|
||||||
.replace(/;/g, "|")
|
.replace(/;/g, "|")
|
||||||
.replace(/,/g, "|")
|
.replace(/,/g, "|")
|
||||||
.replace(/、/g, "|")
|
.replace(/、/g, "|")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const parts = normalized.split("|");
|
const parts = normalized.split("|");
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确定餐食类型
|
* 确定餐食类型
|
||||||
*/
|
*/
|
||||||
function getMealType(mealName: string): MealTypeCode | null {
|
function getMealType(mealName: string): MealTypeCode | null {
|
||||||
if (mealName.includes("早") && !mealName.includes("早点")) return MealTypeCode.BREAKFAST;
|
const header = mealName.trim();
|
||||||
if (mealName.includes("水果") || mealName.includes("早点")) return MealTypeCode.FRUIT;
|
|
||||||
if (mealName.includes("中") || mealName.includes("午餐")) return MealTypeCode.LUNCH;
|
if (header.includes("早餐") || header.includes("早 餐")) return MealTypeCode.BREAKFAST;
|
||||||
if (mealName.includes("午点") || mealName.includes("点心")) return MealTypeCode.SNACK;
|
if (header.includes("早点") || header.includes("水果餐") || header.includes("早 点") || header.includes("早点/水果餐")) return MealTypeCode.FRUIT;
|
||||||
if (mealName.includes("体弱") || mealName.includes("晚")) return MealTypeCode.SPECIAL;
|
if (header.includes("中餐") || header.includes("中 餐")) return MealTypeCode.LUNCH;
|
||||||
return null;
|
if (header.includes("午点") || header.includes("午 点")) return MealTypeCode.SNACK;
|
||||||
|
if (header.includes("体弱儿餐") || header.includes("体弱儿")) return MealTypeCode.SPECIAL;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从表格数据转换为一周食谱结构
|
* 从表格数据转换为一周食谱结构
|
||||||
*/
|
*/
|
||||||
function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
|
function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
|
||||||
const meals: DailyMeal[] = [];
|
const meals: DailyMeal[] = [];
|
||||||
|
|
||||||
// 校验表格数据完整性
|
// 校验表格数据完整性
|
||||||
if (tableData.length < 2 || tableData[0].length < 2) {
|
if (tableData.length < 2 || tableData[0].length < 2) {
|
||||||
console.warn("警告:表格数据不完整");
|
console.warn("警告:表格数据不完整");
|
||||||
return createEmptyMenu();
|
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++) {
|
for (let dayIndex = 0; dayIndex < weekdays.length; dayIndex++) {
|
||||||
const dayName = weekdays[dayIndex];
|
const dayName = weekdays[dayIndex];
|
||||||
if (!dayName) continue;
|
if (!dayName) continue;
|
||||||
|
|
||||||
const columnIndex = dayIndex + 1; // 对应星期几的列索引
|
const columnIndex = dayIndex + 1; // 对应星期几的列索引
|
||||||
|
|
||||||
// 遍历每一行餐型
|
// 遍历每一行餐型
|
||||||
for (const row of mealTypeRows) {
|
for (const row of mealTypeRows) {
|
||||||
const mealName = row[0]?.[0] || ""; // 餐型名称
|
const mealName = row[0]?.[0] || ""; // 餐型名称
|
||||||
if (!mealName) continue;
|
if (!mealName) continue;
|
||||||
|
|
||||||
const mealType = getMealType(mealName);
|
const mealType = getMealType(mealName);
|
||||||
if (!mealType) continue;
|
if (!mealType) continue;
|
||||||
|
|
||||||
// 获取当前餐型的菜品列表
|
// 获取当前餐型的菜品列表
|
||||||
const mealItems = row[columnIndex] || [];
|
const mealItems = row[columnIndex] || [];
|
||||||
|
|
||||||
// 添加到当天餐食列表
|
// 添加到当天餐食列表
|
||||||
meals.push({
|
meals.push({
|
||||||
dayCode: dayIndex + 1,
|
dayCode: dayIndex + 1,
|
||||||
mealCode: mealType,
|
mealCode: mealType,
|
||||||
dishList: mealItems.map((item, idx) => ({
|
dishList: mealItems.map((item, idx) => ({
|
||||||
id: `${dayIndex + 1}-${mealType}-${idx + 1}`,
|
id: `${dayIndex + 1}-${mealType}-${idx + 1}`,
|
||||||
name: { zh: item },
|
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,
|
||||||
for (const day of WEEK_DAYS) {
|
[MealTypeCode.LUNCH]: 2,
|
||||||
for (const meal of MEAL_TYPES) {
|
[MealTypeCode.SPECIAL]: 3,
|
||||||
const exists = meals.find(
|
[MealTypeCode.SNACK]: 4,
|
||||||
(m) => m.dayCode === day.code && m.mealCode === meal.code
|
};
|
||||||
);
|
return orderMap[a.mealCode] - orderMap[b.mealCode];
|
||||||
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 {
|
function createEmptyMenu(): WeeklyMenu {
|
||||||
return {
|
return {
|
||||||
menuId: `menu-${Date.now()}`,
|
menuId: `menu-${Date.now()}`,
|
||||||
weekNumber: "",
|
weekNumber: "",
|
||||||
title: { zh: "食谱", en: "Recipe" },
|
title: {zh: "食谱", en: "Recipe"},
|
||||||
days: WEEK_DAYS.map((d) => ({ code: d.code, name: { zh: d.label } })),
|
days: WEEK_DAYS.map((d) => ({code: d.code, name: {zh: d.label}})),
|
||||||
mealCategories: MEAL_TYPES.map((m, i) => ({
|
mealCategories: MEAL_TYPES.map((m, i) => ({
|
||||||
code: m.code,
|
code: m.code,
|
||||||
name: { zh: m.label },
|
name: {zh: m.label},
|
||||||
sort: i + 1,
|
sort: i + 1,
|
||||||
})),
|
})),
|
||||||
meals: [],
|
meals: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "未上传文件" }, { status: 400 });
|
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
msw: true
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|||||||
Reference in New Issue
Block a user