8b308b6adb
改动了餐食类型标签(水果餐→早点/水果餐、体弱儿专属→体弱儿餐)及排序(体弱儿餐在午点前); 筛选按钮改为可拖拽排序,影响表格和导出顺序; 修复复合标签(早点/水果餐)导出无英文翻译的bug; 排序结果持久化到localStorage
861 lines
36 KiB
TypeScript
861 lines
36 KiB
TypeScript
"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>
|
||
);
|
||
}
|