"use client";
import {useCallback, useEffect, useState} from "react";
import {
AlertCircle,
Apple,
CheckCircle2,
Cookie,
Download,
Heart,
Languages,
Loader2,
Settings,
Sun,
Upload,
UtensilsCrossed,
X,
} from "lucide-react";
import Link from 'next/link'
import {Button} from "@/components/ui/button";
import {
AlignmentType,
Document,
Packer,
PageOrientation,
Paragraph,
Table,
TableCell,
TableLayoutType,
TableRow,
TextRun,
WidthType
} from "docx";
// 餐食类型
enum MealTypeCode {
BREAKFAST = "breakfast",
FRUIT = "fruit",
LUNCH = "lunch",
SNACK = "snack",
SPECIAL = "special",
}
// 语言名称项
interface LangName {
zh: string;
en?: string;
}
// 单个菜品
interface Dish {
id?: string;
name: LangName;
translated?: boolean; // 是否已翻译
}
// 单日食谱
interface DailyMeal {
dayCode: number;
mealCode: MealTypeCode;
dishList: Dish[];
}
// 一周食谱
interface WeeklyMenu {
menuId?: string;
weekNumber?: string;
title: LangName;
days: { code: number; name: LangName }[];
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
meals: DailyMeal[];
}
// 餐食类型配置
const MEAL_TYPES = [
{code: MealTypeCode.BREAKFAST, label: "早餐", icon: Sun},
{code: MealTypeCode.FRUIT, label: "水果餐", icon: Apple},
{code: MealTypeCode.LUNCH, label: "中餐", icon: UtensilsCrossed},
{code: MealTypeCode.SNACK, label: "午点", icon: Cookie},
{code: MealTypeCode.SPECIAL, label: "体弱儿餐", icon: Heart},
];
type UploadState = "idle" | "uploading" | "parsing" | "done" | "error";
type TranslateState = "idle" | "translating" | "done";
/**
* 头部组件
* */
function HarderCompose() {
return (
);
}
export default function RecipeTranslationPage() {
const [uploadState, setUploadState] = useState("idle");
const [translateState, setTranslateState] = useState("idle");
const [selectedMealType, setSelectedMealType] = useState("all");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [fileName, setFileName] = useState("");
const [exportConfig, setExportConfig] = useState<{
untranslatedColor: string;
secondPageHeader: boolean;
orientation: "portrait" | "landscape";
}>({
untranslatedColor: "CC0000",
secondPageHeader: false,
orientation: "landscape",
});
// 加载导出配置
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((result) => {
if (result.success && result.data?.export) {
setExportConfig({
untranslatedColor: result.data.export.untranslatedColor || "CC0000",
secondPageHeader: result.data.export.secondPageHeader || false,
orientation: result.data.export.orientation || "landscape",
});
}
})
.catch(console.error);
}, []);
// 收集所有未翻译的菜品名称
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;
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. 先调用 API 获取最新导出设置
const configResponse = await fetch("/api/config");
const configResult = await configResponse.json();
const exportConfig = configResult.success ? configResult.data?.export : {
untranslatedColor: "CC0000",
orientation: "landscape",
secondPageHeader: false,
};
const {untranslatedColor, orientation, secondPageHeader} = exportConfig;
// 2. 收集所有需要翻译的内容(星期、餐食类型、菜品)
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();
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;
// 3. 构建翻译映射函数
const t = (zh: string): string => translations[zh] || "";
// 4. 构建表格数据 - 生成所有数据行
const dayRows: TableRow[] = [];
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: untranslatedColor})],
})
);
}
}
} else {
cellContent.push(new Paragraph({children: [new TextRun({text: "—", color: "999999"})]}));
}
dayCells.push(
new TableCell({
children: cellContent,
})
);
}
dayRows.push(new TableRow({children: dayCells}));
}
// 5. 创建文档 - 根据设置选择页面方向和分页
const isLandscape = orientation === "landscape";
const pageWidth = isLandscape ? 11906 : 16838;
const pageHeight = isLandscape ? 16838 : 11906;
// 构建表头行
const buildHeaderRow = (): TableRow => {
const headerCells: TableCell[] = [];
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},
})
);
}
return new TableRow({ children: headerCells, tableHeader: false });
};
// 构建 sections
const sections: any[] = [];
sections.push({
properties: {
page: {
size: {
orientation: isLandscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT,
width: pageWidth,
height: pageHeight,
},
margin: {
top: 700,
bottom: 700,
left: 900,
right: 900,
},
},
},
children: [
new Paragraph({
children: [
new TextRun({
text: `${fileName || data.title?.zh || "食谱翻译"}`,
bold: true,
size: 32,
}),
],
alignment: AlignmentType.CENTER,
}),
new Paragraph({children: []}),
new Table({
rows: [buildHeaderRow(), ...dayRows],
width: {size: 100, type: WidthType.PERCENTAGE},
layout: TableLayoutType.FIXED
}),
],
});
const doc = new Document({
sections,
});
// 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) => {
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 (
{/* Header */}
{/* Upload Zone - 闲置状态 */}
{uploadState === "idle" && (
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"
>
点击上传或拖拽 Word 文档
支持 .docx 格式
)}
{/* 上传中 */}
{uploadState === "uploading" && (
)}
{/* 解析中 */}
{uploadState === "parsing" && (
)}
{/* 错误状态 */}
{uploadState === "error" && (
)}
{/* 完成 - 显示结果 */}
{uploadState === "done" && data && (
{/* 成功提示 + 重新上传 */}
文档解析完成
{data.title.zh}
{untranslatedCount > 0 && translateState !== "translating" && (
{untranslatedCount} 个菜品待翻译
)}
{translateState === "done" && untranslatedCount === 0 && (
全部翻译完成
)}
{/* 餐食类型筛选 + 操作按钮 */}
{/* 餐食类型筛选 */}
{MEAL_TYPES.map((meal) => {
const Icon = meal.icon;
return (
);
})}
{/* 操作按钮 */}
{/* 食谱表格 */}
| 星期 |
{selectedMealType === "all"
? MEAL_TYPES.map((meal) => {
const Icon = meal.icon;
return (
{meal.label}
|
);
})
: MEAL_TYPES.find((m) => m.code === selectedMealType)?.icon && (
{
MEAL_TYPES.find((m) => m.code === selectedMealType)
?.label
}
|
)}
{data.days.map((day) => (
| {day.name.zh} |
{selectedMealType === "all" ? (
MEAL_TYPES.map((meal) => {
const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === meal.code
);
return (
{mealData?.dishList.length ? (
mealData.dishList.map((dish) => (
{dish.name.zh}
{dish.name.en && (
{dish.name.en}
)}
))
) : (
—
)}
|
);
})
) : (
{(() => {
const mealData = data.meals.find(
(m) =>
m.dayCode === day.code &&
m.mealCode === selectedMealType
);
return mealData?.dishList.length ? (
{mealData.dishList.map((dish) => (
{dish.name.zh}
{dish.name.en && (
{dish.name.en}
)}
))}
) : (
—
);
})()}
|
)}
))}
)}
);
}