feat: add context menu for individual dish AI translation
- Add right-click/click context menu on dishes in the main table - Menu includes 'AI 翻译 (Beta)' option - Translates single dish and updates the display immediately - Clicking outside closes the context menu
This commit is contained in:
+112
-3
@@ -13,7 +13,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Download, Settings,
|
Download, Settings, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
@@ -126,6 +126,24 @@ export default function RecipeTranslationPage() {
|
|||||||
orientation: "landscape",
|
orientation: "landscape",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 菜品浮框状态
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
dish: Dish | null;
|
||||||
|
dayCode: number;
|
||||||
|
mealCode: MealTypeCode;
|
||||||
|
}>({ visible: false, x: 0, y: 0, dish: null, dayCode: 0, mealCode: MealTypeCode.BREAKFAST });
|
||||||
|
const [aiTranslating, setAiTranslating] = useState(false);
|
||||||
|
|
||||||
|
// 关闭浮框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = () => setContextMenu((prev) => ({ ...prev, visible: false }));
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 加载导出配置
|
// 加载导出配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/config")
|
fetch("/api/config")
|
||||||
@@ -142,6 +160,53 @@ export default function RecipeTranslationPage() {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 单个菜品 AI 翻译
|
||||||
|
const handleAiTranslateSingle = useCallback(async () => {
|
||||||
|
if (!contextMenu.dish || !contextMenu.dish.name.zh || !data) return;
|
||||||
|
|
||||||
|
setAiTranslating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/ai/translate?word=${encodeURIComponent(contextMenu.dish.name.zh)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.english) {
|
||||||
|
// 更新 data 中的翻译结果
|
||||||
|
setData((prevData) => {
|
||||||
|
if (!prevData) return null;
|
||||||
|
return {
|
||||||
|
...prevData,
|
||||||
|
meals: prevData.meals.map((meal) => {
|
||||||
|
if (meal.dayCode === contextMenu.dayCode && meal.mealCode === contextMenu.mealCode) {
|
||||||
|
return {
|
||||||
|
...meal,
|
||||||
|
dishList: meal.dishList.map((dish) => {
|
||||||
|
if (dish.id === contextMenu.dish?.id) {
|
||||||
|
return {
|
||||||
|
...dish,
|
||||||
|
name: { ...dish.name, en: result.english },
|
||||||
|
translated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return dish;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return meal;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setContextMenu((prev) => ({ ...prev, visible: false }));
|
||||||
|
} else {
|
||||||
|
alert(result.error || "翻译失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI translation error:", error);
|
||||||
|
alert("翻译失败,请检查 AI 配置");
|
||||||
|
} finally {
|
||||||
|
setAiTranslating(false);
|
||||||
|
}
|
||||||
|
}, [contextMenu.dish, contextMenu.dayCode, contextMenu.mealCode, data]);
|
||||||
|
|
||||||
// 收集所有未翻译的菜品名称
|
// 收集所有未翻译的菜品名称
|
||||||
const getAllDishNames = useCallback((menu: WeeklyMenu): string[] => {
|
const getAllDishNames = useCallback((menu: WeeklyMenu): string[] => {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -753,7 +818,18 @@ export default function RecipeTranslationPage() {
|
|||||||
mealData.dishList.map((dish) => (
|
mealData.dishList.map((dish) => (
|
||||||
<div
|
<div
|
||||||
key={dish.id}
|
key={dish.id}
|
||||||
className="flex flex-col gap-0.5 text-sm"
|
className="flex flex-col gap-0.5 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
dish,
|
||||||
|
dayCode: day.code,
|
||||||
|
mealCode: meal.code,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
||||||
{dish.name.zh}
|
{dish.name.zh}
|
||||||
@@ -786,7 +862,18 @@ export default function RecipeTranslationPage() {
|
|||||||
{mealData.dishList.map((dish) => (
|
{mealData.dishList.map((dish) => (
|
||||||
<div
|
<div
|
||||||
key={dish.id}
|
key={dish.id}
|
||||||
className="flex flex-col gap-0.5"
|
className="flex flex-col gap-0.5 cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
dish,
|
||||||
|
dayCode: day.code,
|
||||||
|
mealCode: selectedMealType as MealTypeCode,
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
||||||
{dish.name.zh}
|
{dish.name.zh}
|
||||||
@@ -813,6 +900,28 @@ export default function RecipeTranslationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* 菜品浮框 */}
|
||||||
|
{contextMenu.visible && contextMenu.dish && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 min-w-[160px] rounded-md border bg-background shadow-lg py-1"
|
||||||
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-left hover:bg-muted/50 disabled:opacity-50"
|
||||||
|
onClick={() => handleAiTranslateSingle()}
|
||||||
|
disabled={aiTranslating || !contextMenu.dish?.name.zh}
|
||||||
|
>
|
||||||
|
{aiTranslating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
)}
|
||||||
|
<span>AI 翻译 (Beta)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user