feat: 餐食类型支持拖拽排序,调整排列顺序
改动了餐食类型标签(水果餐→早点/水果餐、体弱儿专属→体弱儿餐)及排序(体弱儿餐在午点前); 筛选按钮改为可拖拽排序,影响表格和导出顺序; 修复复合标签(早点/水果餐)导出无英文翻译的bug; 排序结果持久化到localStorage
This commit is contained in:
@@ -43,9 +43,9 @@ interface WeeklyMenu {
|
||||
// 餐食类型配置
|
||||
const MEAL_TYPES = [
|
||||
{ code: MealTypeCode.BREAKFAST, label: "早餐" },
|
||||
{ code: MealTypeCode.FRUIT, label: "水果餐" },
|
||||
{ code: MealTypeCode.FRUIT, label: "早点/水果餐" },
|
||||
{ code: MealTypeCode.LUNCH, label: "中餐" },
|
||||
{ code: MealTypeCode.SPECIAL, label: "体弱儿专属" },
|
||||
{ code: MealTypeCode.SPECIAL, label: "体弱儿餐" },
|
||||
{ code: MealTypeCode.SNACK, label: "午点" },
|
||||
];
|
||||
|
||||
@@ -217,8 +217,8 @@ function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
|
||||
[MealTypeCode.BREAKFAST]: 0,
|
||||
[MealTypeCode.FRUIT]: 1,
|
||||
[MealTypeCode.LUNCH]: 2,
|
||||
[MealTypeCode.SNACK]: 3,
|
||||
[MealTypeCode.SPECIAL]: 4,
|
||||
[MealTypeCode.SPECIAL]: 3,
|
||||
[MealTypeCode.SNACK]: 4,
|
||||
};
|
||||
return orderMap[a.mealCode] - orderMap[b.mealCode];
|
||||
}),
|
||||
|
||||
+109
-14
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user