Files
recipe_tool/app/page.tsx
T
hanhan 6e1d2c6728 feat: 新增135编辑器转换子功能
- 新增 /editor135 子页面:上传幼儿园食谱docx → 解析并填充135模板 → 预览/源代码/解析结果三视图
- 新增 /api/editor135/render 解析接口(jsdom 解析填充,逐条回读校验 mismatch)
- 新增 /api/editor135/img 微信CDN图片代理(解决预览防盗链,仅允许 *.qpic.cn)
- 缺失星期自动删除:docx 缺某天(含周末/间隔天)时整段移除该天内容,尾部三审三校/温馨提示保留
- 逐节点精确区间删除算法:处理相邻天共用容器、嵌套结构,不误删保留天
- 新增依赖 jsdom + @types/jsdom;附 6 场景回归测试脚本 scripts/test-remove-days.ts
- 首页 header 增加「135编辑器转换」导航入口
2026-09-08 23:18:53 +08:00

869 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<UtensilsCrossed className="h-5 w-5"/>
</div>
<div>
<h1 className="text-xl font-semibold">食谱翻译工具</h1>
<p className="text-sm text-muted-foreground">Word 文档解析 · 智能翻译</p>
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/editor135">
<Button variant="outline" size="sm" className="gap-1.5">
<Cookie className="h-4 w-4"/>
135编辑器转换
</Button>
</Link>
<Link href="/settings">
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4"/>
</Button>
</Link>
</div>
</div>
</div>
);
}
export default function RecipeTranslationPage() {
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [translateState, setTranslateState] = useState<TranslateState>("idle");
const [selectedMealType, setSelectedMealType] = useState<MealTypeCode | "all">("all");
const [data, setData] = useState<WeeklyMenu | null>(null);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string>("");
const [exportConfig, setExportConfig] = useState<{
untranslatedColor: string;
secondPageHeader: boolean;
orientation: "portrait" | "landscape";
}>({
untranslatedColor: "CC0000",
secondPageHeader: false,
orientation: "landscape",
});
// 餐食类型拖拽排序(持久化到 localStorage)
const [mealOrder, setMealOrder] = useState<MealTypeCode[]>(() => {
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<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(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<string, string | null>;
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<string>();
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<string, string | null>;
// 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<HTMLInputElement>) => {
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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card">
<HarderCompose/>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Upload Zone - 闲置状态 */}
{uploadState === "idle" && (
<div
onDrop={handleDrop}
onDragOver={(e) => 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"
>
<input
type="file"
accept=".docx"
onChange={handleFileInput}
className="absolute inset-0 cursor-pointer opacity-0"
/>
<div
className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary transition-transform group-hover:scale-110">
<Upload className="h-8 w-8"/>
</div>
<p className="mb-1 text-lg font-medium">点击上传或拖拽 Word 文档</p>
<p className="text-sm text-muted-foreground">支持 .docx 格式</p>
</div>
)}
{/* 上传中 */}
{uploadState === "uploading" && (
<div className="flex flex-col items-center justify-center rounded-2xl border bg-card p-12">
<Loader2 className="mb-4 h-12 w-12 animate-spin text-primary"/>
<p className="text-lg font-medium">正在上传文件...</p>
</div>
)}
{/* 解析中 */}
{uploadState === "parsing" && (
<div className="flex flex-col items-center justify-center rounded-2xl border bg-card p-12">
<Loader2 className="mb-4 h-12 w-12 animate-spin text-primary"/>
<p className="text-lg font-medium">正在解析文档内容...</p>
<p className="mt-1 text-sm text-muted-foreground">提取文字并识别食谱结构</p>
</div>
)}
{/* 错误状态 */}
{uploadState === "error" && (
<div
className="flex flex-col items-center justify-center rounded-2xl border border-destructive/50 bg-destructive/5 p-12">
<div
className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<X className="h-8 w-8"/>
</div>
<p className="mb-1 text-lg font-medium text-destructive">{error}</p>
<Button variant="outline" onClick={reset} className="mt-4">
重新上传
</Button>
</div>
)}
{/* 完成 - 显示结果 */}
{uploadState === "done" && data && (
<div className="space-y-6">
{/* 成功提示 + 重新上传 */}
<div className="flex items-center justify-between rounded-xl border bg-card p-4">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-primary"/>
<span className="font-medium">文档解析完成</span>
<span className="text-sm text-muted-foreground">{data.title.zh}</span>
{untranslatedCount > 0 && translateState !== "translating" && (
<span className="flex items-center gap-1 text-sm text-destructive">
<AlertCircle className="h-4 w-4"/>
{untranslatedCount} 个菜品待翻译
</span>
)}
{translateState === "done" && untranslatedCount === 0 && (
<span className="flex items-center gap-1 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4"/>
全部翻译完成
</span>
)}
</div>
<Button variant="outline" size="sm" onClick={reset}>
重新上传
</Button>
</div>
{/* 餐食类型筛选 + 操作按钮 */}
<div className="flex flex-wrap items-center justify-between gap-4">
{/* 餐食类型筛选(拖拽排序) */}
<div className="flex flex-wrap gap-2">
<Button
variant={selectedMealType === "all" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedMealType("all")}
>
全部
</Button>
{orderedMealTypes.map((meal, index) => {
const Icon = meal.icon;
const isDragging = dragIndex === index;
const isDropTarget = dropIndex === index && dragIndex !== null && dragIndex !== index;
return (
<Button
key={meal.code}
draggable
variant={selectedMealType === meal.code ? "default" : "outline"}
size="sm"
onClick={() => setSelectedMealType(meal.code)}
onDragStart={() => handleMealDragStart(index)}
onDragOver={(e) => handleMealDragOver(e, index)}
onDragLeave={handleMealDragLeave}
onDrop={() => handleMealDrop(index)}
onDragEnd={handleMealDragEnd}
className={`gap-1.5 cursor-grab active:cursor-grabbing select-none ${
isDragging ? "opacity-40 ring-2 ring-primary/50" : ""
} ${
isDropTarget ? "ring-2 ring-primary" : ""
}`}
>
<Icon className="h-4 w-4"/>
{meal.label}
</Button>
);
})}
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
variant="outline"
className="gap-2"
onClick={handleExport}
disabled={translateState === "translating"}
>
<Download className="h-4 w-4"/>
导出翻译结果
</Button>
<Button
className="gap-2"
onClick={handleTranslateAll}
disabled={translateState === "translating" || untranslatedCount === 0}
>
{translateState === "translating" ? (
<>
<Loader2 className="h-4 w-4 animate-spin"/>
翻译中...
</>
) : (
<>
<Languages className="h-4 w-4"/>
一键翻译全部
{untranslatedCount > 0 &&
<span className="text-xs opacity-70">({untranslatedCount})</span>}
</>
)}
</Button>
</div>
</div>
{/* 食谱表格 */}
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="min-w-[80px] px-4 py-3 text-left font-medium">星期</th>
{selectedMealType === "all"
? orderedMealTypes.map((meal) => {
const Icon = meal.icon;
return (
<th key={meal.code} className="px-4 py-3 text-center font-medium">
<div className="flex items-center justify-center gap-1.5">
<Icon className="h-4 w-4"/>
{meal.label}
</div>
</th>
);
})
: MEAL_TYPES.find((m) => m.code === selectedMealType)?.icon && (
<th className="px-4 py-3 text-center font-medium">
{
MEAL_TYPES.find((m) => m.code === selectedMealType)
?.label
}
</th>
)}
</tr>
</thead>
<tbody>
{data.days.map((day) => (
<tr key={day.code} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{day.name.zh}</td>
{selectedMealType === "all" ? (
orderedMealTypes.map((meal) => {
const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === meal.code
);
return (
<td key={meal.code} className="px-4 py-3">
<div className="flex flex-col gap-1">
{mealData?.dishList.length ? (
mealData.dishList.map((dish) => (
<div
key={dish.id}
className="flex flex-col gap-0.5 text-sm"
>
<span style={{color: dish.name.en ? undefined : exportConfig.untranslatedColor}}>
{dish.name.zh}
</span>
{dish.name.en && (
<span
className="text-xs text-muted-foreground">
{dish.name.en}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground">—</span>
)}
</div>
</td>
);
})
) : (
<td className="px-4 py-3">
{(() => {
const mealData = data.meals.find(
(m) =>
m.dayCode === day.code &&
m.mealCode === selectedMealType
);
return mealData?.dishList.length ? (
<div className="flex flex-col gap-1">
{mealData.dishList.map((dish) => (
<div
key={dish.id}
className="flex flex-col gap-0.5"
>
<span style={{color: dish.name.en ? undefined : exportConfig.untranslatedColor}}>
{dish.name.zh}
</span>
{dish.name.en && (
<span className="text-xs text-muted-foreground">
{dish.name.en}
</span>
)}
</div>
))}
</div>
) : (
<span className="text-muted-foreground">—</span>
);
})()}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
);
}