Files
hanhan 8b308b6adb feat: 餐食类型支持拖拽排序,调整排列顺序
改动了餐食类型标签(水果餐→早点/水果餐、体弱儿专属→体弱儿餐)及排序(体弱儿餐在午点前); 筛选按钮改为可拖拽排序,影响表格和导出顺序; 修复复合标签(早点/水果餐)导出无英文翻译的bug; 排序结果持久化到localStorage
2026-05-24 21:45:31 +08:00

861 lines
36 KiB
TypeScript
Raw Permalink 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>
<Link href="/settings">
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4"/>
</Button>
</Link>
</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>
);
}