feat: 餐食类型支持拖拽排序,调整排列顺序

改动了餐食类型标签(水果餐→早点/水果餐、体弱儿专属→体弱儿餐)及排序(体弱儿餐在午点前); 筛选按钮改为可拖拽排序,影响表格和导出顺序; 修复复合标签(早点/水果餐)导出无英文翻译的bug; 排序结果持久化到localStorage
This commit is contained in:
2026-05-24 21:45:31 +08:00
parent 8bc63cbdf2
commit 8b308b6adb
2 changed files with 113 additions and 18 deletions
+4 -4
View File
@@ -43,9 +43,9 @@ interface WeeklyMenu {
// 餐食类型配置 // 餐食类型配置
const MEAL_TYPES = [ const MEAL_TYPES = [
{ code: MealTypeCode.BREAKFAST, label: "早餐" }, { code: MealTypeCode.BREAKFAST, label: "早餐" },
{ code: MealTypeCode.FRUIT, label: "水果餐" }, { code: MealTypeCode.FRUIT, label: "早点/水果餐" },
{ code: MealTypeCode.LUNCH, label: "中餐" }, { code: MealTypeCode.LUNCH, label: "中餐" },
{ code: MealTypeCode.SPECIAL, label: "体弱儿专属" }, { code: MealTypeCode.SPECIAL, label: "体弱儿" },
{ code: MealTypeCode.SNACK, label: "午点" }, { code: MealTypeCode.SNACK, label: "午点" },
]; ];
@@ -217,8 +217,8 @@ function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
[MealTypeCode.BREAKFAST]: 0, [MealTypeCode.BREAKFAST]: 0,
[MealTypeCode.FRUIT]: 1, [MealTypeCode.FRUIT]: 1,
[MealTypeCode.LUNCH]: 2, [MealTypeCode.LUNCH]: 2,
[MealTypeCode.SNACK]: 3, [MealTypeCode.SPECIAL]: 3,
[MealTypeCode.SPECIAL]: 4, [MealTypeCode.SNACK]: 4,
}; };
return orderMap[a.mealCode] - orderMap[b.mealCode]; return orderMap[a.mealCode] - orderMap[b.mealCode];
}), }),
+109 -14
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import {useCallback, useEffect, useState} from "react"; import {useCallback, useEffect, useMemo, useState} from "react";
import { import {
AlertCircle, AlertCircle,
Apple, Apple,
@@ -74,10 +74,10 @@ interface WeeklyMenu {
// 餐食类型配置 // 餐食类型配置
const MEAL_TYPES = [ const MEAL_TYPES = [
{code: MealTypeCode.BREAKFAST, label: "早餐", icon: Sun}, {code: MealTypeCode.BREAKFAST, label: "早餐", icon: Sun},
{code: MealTypeCode.FRUIT, label: "水果餐", icon: Apple}, {code: MealTypeCode.FRUIT, label: "早点/水果餐", icon: Apple},
{code: MealTypeCode.LUNCH, label: "中餐", icon: UtensilsCrossed}, {code: MealTypeCode.LUNCH, label: "中餐", icon: UtensilsCrossed},
{code: MealTypeCode.SNACK, label: "午点", icon: Cookie},
{code: MealTypeCode.SPECIAL, label: "体弱儿餐", icon: Heart}, {code: MealTypeCode.SPECIAL, label: "体弱儿餐", icon: Heart},
{code: MealTypeCode.SNACK, label: "午点", icon: Cookie},
]; ];
type UploadState = "idle" | "uploading" | "parsing" | "done" | "error"; type UploadState = "idle" | "uploading" | "parsing" | "done" | "error";
@@ -127,6 +127,69 @@ export default function RecipeTranslationPage() {
orientation: "landscape", 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(() => { useEffect(() => {
fetch("/api/config") fetch("/api/config")
@@ -231,6 +294,11 @@ export default function RecipeTranslationPage() {
}; };
const {untranslatedColor, orientation, secondPageHeader} = exportConfig; const {untranslatedColor, orientation, secondPageHeader} = exportConfig;
// 使用当前拖拽排序后的餐食类型顺序
const exportMealTypes = mealOrder.map(
code => MEAL_TYPES.find(m => m.code === code)!
);
// 2. 收集所有需要翻译的内容(星期、餐食类型、菜品) // 2. 收集所有需要翻译的内容(星期、餐食类型、菜品)
const allNames: string[] = []; const allNames: string[] = [];
@@ -241,9 +309,16 @@ export default function RecipeTranslationPage() {
} }
} }
// 收集餐食类型名称 // 收集餐食类型名称(按当前排序)
for (const meal of MEAL_TYPES) { for (const meal of exportMealTypes) {
allNames.push(meal.label); allNames.push(meal.label);
// 复合标签(如"早点/水果餐")拆分添加各部分,确保能被翻译
if (meal.label.includes("/")) {
for (const part of meal.label.split("/")) {
const trimmed = part.trim();
if (trimmed) allNames.push(trimmed);
}
}
} }
// 收集所有菜品名称(去重) // 收集所有菜品名称(去重)
@@ -273,7 +348,15 @@ export default function RecipeTranslationPage() {
const translations = result.translations as Record<string, string | null>; const translations = result.translations as Record<string, string | null>;
// 3. 构建翻译映射函数 // 3. 构建翻译映射函数
const t = (zh: string): string => translations[zh] || ""; 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. 构建表格数据 - 生成所有数据行 // 4. 构建表格数据 - 生成所有数据行
const dayRows: TableRow[] = []; const dayRows: TableRow[] = [];
@@ -295,7 +378,7 @@ export default function RecipeTranslationPage() {
}), }),
]; ];
for (const mealType of MEAL_TYPES) { for (const mealType of exportMealTypes) {
const mealData = data.meals.find( const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === mealType.code (m) => m.dayCode === day.code && m.mealCode === mealType.code
); );
@@ -356,7 +439,7 @@ export default function RecipeTranslationPage() {
width: {size: 12, type: WidthType.PERCENTAGE}, width: {size: 12, type: WidthType.PERCENTAGE},
}), }),
); );
for (const mealType of MEAL_TYPES) { for (const mealType of exportMealTypes) {
const zhText = mealType.label; const zhText = mealType.label;
const enText = t(zhText); const enText = t(zhText);
headerCells.push( headerCells.push(
@@ -435,7 +518,7 @@ export default function RecipeTranslationPage() {
console.error("Export error:", err); console.error("Export error:", err);
alert("导出失败,请重试"); alert("导出失败,请重试");
} }
}, [data, fileName]); }, [data, fileName, mealOrder]);
const handleFileUpload = useCallback( const handleFileUpload = useCallback(
async (file: File) => { async (file: File) => {
@@ -597,7 +680,7 @@ export default function RecipeTranslationPage() {
{/* 餐食类型筛选 + 操作按钮 */} {/* 餐食类型筛选 + 操作按钮 */}
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
{/* 餐食类型筛选 */} {/* 餐食类型筛选(拖拽排序) */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
variant={selectedMealType === "all" ? "default" : "outline"} variant={selectedMealType === "all" ? "default" : "outline"}
@@ -606,15 +689,27 @@ export default function RecipeTranslationPage() {
> >
</Button> </Button>
{MEAL_TYPES.map((meal) => { {orderedMealTypes.map((meal, index) => {
const Icon = meal.icon; const Icon = meal.icon;
const isDragging = dragIndex === index;
const isDropTarget = dropIndex === index && dragIndex !== null && dragIndex !== index;
return ( return (
<Button <Button
key={meal.code} key={meal.code}
draggable
variant={selectedMealType === meal.code ? "default" : "outline"} variant={selectedMealType === meal.code ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setSelectedMealType(meal.code)} onClick={() => setSelectedMealType(meal.code)}
className="gap-1.5" 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"/> <Icon className="h-4 w-4"/>
{meal.label} {meal.label}
@@ -663,7 +758,7 @@ export default function RecipeTranslationPage() {
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="min-w-[80px] px-4 py-3 text-left font-medium"></th> <th className="min-w-[80px] px-4 py-3 text-left font-medium"></th>
{selectedMealType === "all" {selectedMealType === "all"
? MEAL_TYPES.map((meal) => { ? orderedMealTypes.map((meal) => {
const Icon = meal.icon; const Icon = meal.icon;
return ( return (
<th key={meal.code} className="px-4 py-3 text-center font-medium"> <th key={meal.code} className="px-4 py-3 text-center font-medium">
@@ -689,7 +784,7 @@ export default function RecipeTranslationPage() {
<tr key={day.code} className="border-b last:border-0 hover:bg-muted/30"> <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> <td className="px-4 py-3 font-medium">{day.name.zh}</td>
{selectedMealType === "all" ? ( {selectedMealType === "all" ? (
MEAL_TYPES.map((meal) => { orderedMealTypes.map((meal) => {
const mealData = data.meals.find( const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === meal.code (m) => m.dayCode === day.code && m.mealCode === meal.code
); );