"use client"; import {useCallback, useEffect, useMemo, 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.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 [exportConfig, setExportConfig] = useState<{ untranslatedColor: string; secondPageHeader: boolean; orientation: "portrait" | "landscape"; }>({ untranslatedColor: "CC0000", secondPageHeader: false, orientation: "landscape", }); // 餐食类型拖拽排序(持久化到 localStorage) const [mealOrder, setMealOrder] = useState(() => { try { const saved = localStorage.getItem("mealOrder"); if (saved) { const parsed = JSON.parse(saved); if (Array.isArray(parsed) && parsed.every((c: string) => MEAL_TYPES.some(m => m.code === c))) { return parsed; } } } catch {} return MEAL_TYPES.map(m => m.code); }); // 按当前排序的餐食类型列表 const orderedMealTypes = useMemo(() => mealOrder.map(code => MEAL_TYPES.find(m => m.code === code)!) , [mealOrder]); // 持久化排序顺序 useEffect(() => { localStorage.setItem("mealOrder", JSON.stringify(mealOrder)); }, [mealOrder]); // 拖拽排序状态 const [dragIndex, setDragIndex] = useState(null); const [dropIndex, setDropIndex] = useState(null); const handleMealDragStart = (index: number) => { setDragIndex(index); }; const handleMealDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (index !== dragIndex) { setDropIndex(index); } }; const handleMealDragLeave = () => { setDropIndex(null); }; const handleMealDrop = (index: number) => { if (dragIndex === null || dragIndex === index) { setDragIndex(null); setDropIndex(null); return; } const newOrder = [...mealOrder]; const [moved] = newOrder.splice(dragIndex, 1); newOrder.splice(index, 0, moved); setMealOrder(newOrder); setDragIndex(null); setDropIndex(null); }; const handleMealDragEnd = () => { setDragIndex(null); setDropIndex(null); }; // 加载导出配置 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; // 使用当前拖拽排序后的餐食类型顺序 const exportMealTypes = mealOrder.map( code => MEAL_TYPES.find(m => m.code === code)! ); // 2. 收集所有需要翻译的内容(星期、餐食类型、菜品) const allNames: string[] = []; // 收集星期名称(使用实际数据中的星期) for (const day of data.days) { if (day.name.zh) { allNames.push(day.name.zh); } } // 收集餐食类型名称(按当前排序) for (const meal of exportMealTypes) { allNames.push(meal.label); // 复合标签(如"早点/水果餐")拆分添加各部分,确保能被翻译 if (meal.label.includes("/")) { for (const part of meal.label.split("/")) { const trimmed = part.trim(); if (trimmed) allNames.push(trimmed); } } } // 收集所有菜品名称(去重) 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 => { const cached = translations[zh]; if (cached) return cached; // 复合标签拆分翻译:如 "早点/水果餐" → 分别翻译各部分再组合 if (zh.includes("/")) { return zh.split("/").map(p => translations[p.trim()] || p.trim()).join(" / "); } return ""; }; // 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 exportMealTypes) { 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 exportMealTypes) { 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, mealOrder]); 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 && ( 全部翻译完成 )}
{/* 餐食类型筛选 + 操作按钮 */}
{/* 餐食类型筛选(拖拽排序) */}
{orderedMealTypes.map((meal, index) => { const Icon = meal.icon; const isDragging = dragIndex === index; const isDropTarget = dropIndex === index && dragIndex !== null && dragIndex !== index; return ( ); })}
{/* 操作按钮 */}
{/* 食谱表格 */}
{selectedMealType === "all" ? orderedMealTypes.map((meal) => { const Icon = meal.icon; return ( ); }) : MEAL_TYPES.find((m) => m.code === selectedMealType)?.icon && ( )} {data.days.map((day) => ( {selectedMealType === "all" ? ( orderedMealTypes.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} )}
))}
) : ( ); })()}
)}
); }