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
+109 -14
View File
@@ -1,6 +1,6 @@
"use client";
import {useCallback, useEffect, useState} from "react";
import {useCallback, useEffect, useMemo, useState} from "react";
import {
AlertCircle,
Apple,
@@ -74,10 +74,10 @@ interface WeeklyMenu {
// 餐食类型配置
const MEAL_TYPES = [
{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.SNACK, label: "午点", icon: Cookie},
{code: MealTypeCode.SPECIAL, label: "体弱儿餐", icon: Heart},
{code: MealTypeCode.SNACK, label: "午点", icon: Cookie},
];
type UploadState = "idle" | "uploading" | "parsing" | "done" | "error";
@@ -127,6 +127,69 @@ export default function RecipeTranslationPage() {
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")
@@ -231,6 +294,11 @@ export default function RecipeTranslationPage() {
};
const {untranslatedColor, orientation, secondPageHeader} = exportConfig;
// 使用当前拖拽排序后的餐食类型顺序
const exportMealTypes = mealOrder.map(
code => MEAL_TYPES.find(m => m.code === code)!
);
// 2. 收集所有需要翻译的内容(星期、餐食类型、菜品)
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);
// 复合标签(如"早点/水果餐")拆分添加各部分,确保能被翻译
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>;
// 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. 构建表格数据 - 生成所有数据行
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(
(m) => m.dayCode === day.code && m.mealCode === mealType.code
);
@@ -356,7 +439,7 @@ export default function RecipeTranslationPage() {
width: {size: 12, type: WidthType.PERCENTAGE},
}),
);
for (const mealType of MEAL_TYPES) {
for (const mealType of exportMealTypes) {
const zhText = mealType.label;
const enText = t(zhText);
headerCells.push(
@@ -435,7 +518,7 @@ export default function RecipeTranslationPage() {
console.error("Export error:", err);
alert("导出失败,请重试");
}
}, [data, fileName]);
}, [data, fileName, mealOrder]);
const handleFileUpload = useCallback(
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 gap-2">
<Button
variant={selectedMealType === "all" ? "default" : "outline"}
@@ -606,15 +689,27 @@ export default function RecipeTranslationPage() {
>
</Button>
{MEAL_TYPES.map((meal) => {
{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)}
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"/>
{meal.label}
@@ -663,7 +758,7 @@ export default function RecipeTranslationPage() {
<tr className="border-b bg-muted/50">
<th className="min-w-[80px] px-4 py-3 text-left font-medium"></th>
{selectedMealType === "all"
? MEAL_TYPES.map((meal) => {
? orderedMealTypes.map((meal) => {
const Icon = meal.icon;
return (
<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">
<td className="px-4 py-3 font-medium">{day.name.zh}</td>
{selectedMealType === "all" ? (
MEAL_TYPES.map((meal) => {
orderedMealTypes.map((meal) => {
const mealData = data.meals.find(
(m) => m.dayCode === day.code && m.mealCode === meal.code
);