"use client"; import {useState, useCallback} from "react"; import { Upload, Sun, Apple, UtensilsCrossed, Cookie, Heart, Languages, CheckCircle2, Loader2, X, AlertCircle, Download, Settings, } from "lucide-react"; import Link from 'next/link' import {Button} from "@/components/ui/button"; import { Document, Packer, Table, TableRow, TableCell, TextRun, Paragraph, AlignmentType, WidthType, PageOrientation, TableLayoutType } 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.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 (

食谱翻译工具

Word 文档解析 · 智能翻译

); } 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 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. 收集所有需要翻译的内容(星期、餐食类型、菜品) 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 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) => { 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" && (

{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_TYPES.find((m) => m.code === selectedMealType)?.icon && ( )} {data.days.map((day) => ( {selectedMealType === "all" ? ( MEAL_TYPES.map((meal) => { const mealData = data.meals.find( (m) => m.dayCode === day.code && m.mealCode === meal.code ); return ( ); }) ) : ( )} ))}
星期
{meal.label}
{ MEAL_TYPES.find((m) => m.code === selectedMealType) ?.label }
{day.name.zh}
{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} )}
))}
) : ( ); })()}
)}
); }