lty/qy-lty-admin/components/food/add-food-dialog.tsx
pmc bd95ba470c feat: update admin panel, API modules, and add migrations
- Update food, outfits, props, home-decor pages and components
- Add permissions page and sidebar updates
- Update API client and all API modules (auth, food, dances, etc.)
- Add card model migrations for optional fields
- Update Django views, serializers, and authentication
- Add affinity level migrations and user app updates
- Add project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:06:50 +08:00

453 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, AlertTriangle, Loader2 } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { FoodMediaUpload } from "./food-media-upload"
import { createFood, updateFood } from "@/lib/api/food"
import { useToast } from "@/components/ui/use-toast"
import type { Food } from "./food-detail-dialog"
type AddFoodDialogProps = {
mode?: "create" | "edit"
initialFood?: Food
open?: boolean
onOpenChange?: (open: boolean) => void
onSave?: (food: Food) => void
}
export function AddFoodDialog({
mode = "create",
initialFood,
open: controlledOpen,
onOpenChange: setControlledOpen,
onSave,
}: AddFoodDialogProps) {
const { toast } = useToast()
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// 表单状态
const [name, setName] = useState("")
const [foodType, setFoodType] = useState("")
const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("")
const [calories, setCalories] = useState<number | undefined>()
const [tasteTags, setTasteTags] = useState("")
const [nutritionalValue, setNutritionalValue] = useState("")
const [effectDescription, setEffectDescription] = useState("")
const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [animationUrl, setAnimationUrl] = useState<string | undefined>()
const [audioUrl, setAudioUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState(
"FOD" +
Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0"),
)
// 当编辑模式且有食物数据时,初始化表单
useEffect(() => {
if (mode === "edit" && initialFood) {
console.log("🔧 初始化编辑模式表单:", initialFood)
setName(initialFood.name)
setFoodType(initialFood.food_type)
setRarity(initialFood.rarity)
setDescription(initialFood.description)
setCalories(initialFood.calories)
setTasteTags(initialFood.taste_tags || "")
setNutritionalValue(initialFood.nutritional_value || "")
setEffectDescription(initialFood.effect_description || "")
setIsLimited(initialFood.is_limited || false)
setPreviewId(initialFood.id)
setImageUrl(initialFood.image)
setAnimationUrl(initialFood.animation_file)
setAudioUrl(initialFood.sound_effect)
}
}, [mode, initialFood])
// 处理受控和非受控开关状态
useEffect(() => {
if (controlledOpen !== undefined) {
setOpen(controlledOpen)
}
}, [controlledOpen])
// 处理对话框关闭
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
if (setControlledOpen) {
setControlledOpen(newOpen)
}
// 如果关闭对话框,重置表单(仅在创建模式下)
if (!newOpen && mode === "create") {
resetForm()
}
}
// 重置表单
const resetForm = () => {
if (mode === "create") {
setName("")
setFoodType("")
setRarity("")
setDescription("")
setCalories(undefined)
setTasteTags("")
setNutritionalValue("")
setEffectDescription("")
setIsLimited(false)
setImageUrl(undefined)
setAnimationUrl(undefined)
setAudioUrl(undefined)
setPreviewId(
"FOD" +
Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0"),
)
}
}
const handleSubmit = async () => {
// 表单验证
if (!name || !foodType || !rarity || !description) {
toast({
title: "表单验证失败",
description: "请填写所有必填字段!",
variant: "destructive",
})
return
}
// 图片是必填的(至少在创建模式下)
if (mode === "create" && !imageUrl) {
toast({
title: "表单验证失败",
description: "请上传食物图片!",
variant: "destructive",
})
return
}
setIsSubmitting(true)
try {
// 构建食物数据
const foodData: Partial<Food> = {
name,
food_type: foodType,
rarity,
description,
image: imageUrl,
animation_file: animationUrl,
sound_effect: audioUrl,
calories,
taste_tags: tasteTags,
nutritional_value: nutritionalValue,
effect_description: effectDescription,
is_limited: isLimited,
...(mode === "create" ? { status: "draft" } : {}),
}
let result: Food
if (mode === "create") {
// 创建新食物
const response = await createFood(foodData)
if (!response.success) {
throw new Error(response.message || "创建食物失败")
}
result = response.data
console.log("✅ 食物创建成功:", result)
} else {
// 更新食物
if (!initialFood?.id) {
throw new Error("缺少食物ID无法更新")
}
console.log("🔄 更新食物数据:", { id: initialFood.id, foodData })
const response = await updateFood(initialFood.id, foodData)
if (!response.success) {
throw new Error(response.message || "更新食物失败")
}
result = response.data
console.log("✅ 食物更新成功:", result)
}
// 调用保存回调
if (onSave) {
onSave(result)
}
// 显示成功消息
toast({
title: mode === "create" ? "创建成功" : "更新成功",
description: `食物 ${name}${mode === "create" ? "创建" : "更新"}成功!`,
})
// 关闭对话框
handleOpenChange(false)
} catch (error) {
console.error(mode === "create" ? "创建食物失败:" : "更新食物失败:", error)
const errorMessage = error instanceof Error ? error.message : "操作失败,请重试!"
toast({
title: mode === "create" ? "创建失败" : "更新失败",
description: errorMessage,
variant: "destructive",
})
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
{mode === "create" && (
<DialogTrigger asChild>
<Button className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 transition-all duration-300 shadow-md hover:shadow-lg">
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-[550px] max-h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
{mode === "create" ? "添加新食物" : "编辑食物"}
</DialogTitle>
<DialogDescription>
{mode === "create" ? "填写食物信息以创建新的食物卡牌。创建后将生成唯一的卡牌ID。" : "修改食物信息。"}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 px-6 py-4 flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
placeholder="输入食物名称"
className="border-gray-300 focus-visible:ring-pink-500"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="type" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Select value={foodType} onValueChange={setFoodType} required>
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
<SelectValue placeholder="选择食物类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fruit"></SelectItem>
<SelectItem value="vegetable"></SelectItem>
<SelectItem value="meat"></SelectItem>
<SelectItem value="seafood"></SelectItem>
<SelectItem value="dairy"></SelectItem>
<SelectItem value="grain"></SelectItem>
<SelectItem value="snack"></SelectItem>
<SelectItem value="drink"></SelectItem>
<SelectItem value="dessert"></SelectItem>
<SelectItem value="spice"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rarity" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Select value={rarity} onValueChange={setRarity} required>
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
<SelectValue placeholder="选择稀有度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="common"></SelectItem>
<SelectItem value="rare"></SelectItem>
<SelectItem value="epic"></SelectItem>
<SelectItem value="legendary"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="quantity" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Input
id="quantity"
type="number"
min="1"
defaultValue="1000"
className="border-gray-300 focus-visible:ring-pink-500"
required
disabled={mode === "edit"}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-right">
<span className="text-red-500">*</span>
</Label>
<Textarea
id="description"
placeholder="输入食物描述"
className="min-h-[100px] border-gray-300 focus-visible:ring-pink-500"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="calories" className="text-right">
</Label>
<Input
id="calories"
type="number"
min="0"
placeholder="输入卡路里值"
className="border-gray-300 focus-visible:ring-pink-500"
value={calories || ""}
onChange={(e) => setCalories(e.target.value ? parseInt(e.target.value) : undefined)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tasteTags" className="text-right">
</Label>
<Input
id="tasteTags"
placeholder="如:甜,脆,清爽"
className="border-gray-300 focus-visible:ring-pink-500"
value={tasteTags}
onChange={(e) => setTasteTags(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="nutritionalValue" className="text-right">
</Label>
<Textarea
id="nutritionalValue"
placeholder="描述食物的营养价值"
className="min-h-[80px] border-gray-300 focus-visible:ring-pink-500"
value={nutritionalValue}
onChange={(e) => setNutritionalValue(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="effectDescription" className="text-right">
</Label>
<Textarea
id="effectDescription"
placeholder="描述食物的功效和作用"
className="min-h-[80px] border-gray-300 focus-visible:ring-pink-500"
value={effectDescription}
onChange={(e) => setEffectDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>}
</Label>
<FoodMediaUpload
imageUrl={imageUrl}
animationUrl={animationUrl}
audioUrl={audioUrl}
onImageUpload={setImageUrl}
onAnimationUpload={setAnimationUrl}
onAudioUpload={setAudioUrl}
onRemove={(type) => {
if (type === 'image') setImageUrl(undefined)
else if (type === 'animation') setAnimationUrl(undefined)
else if (type === 'audio') setAudioUrl(undefined)
}}
disabled={isSubmitting}
/>
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch id="limited" checked={isLimited} onCheckedChange={setIsLimited} />
<Label htmlFor="limited" className="cursor-pointer">
</Label>
</div>
{isLimited && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start">
<AlertTriangle className="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-700">
<p className="font-medium"></p>
<p></p>
</div>
</div>
)}
{mode === "create" && (
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="text-sm font-medium mb-2"></h3>
<p className="text-sm text-gray-600">
ID: <span className="font-medium text-pink-600">{previewId}</span>
</p>
<p className="text-sm text-gray-600 mt-1">
: <span className="font-medium"></span>
</p>
</div>
)}
</div>
<DialogFooter className="px-6 py-4 border-t flex-shrink-0">
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
</Button>
<Button
className="bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{mode === "create" ? "创建中..." : "更新中..."}
</>
) : mode === "create" ? (
"创建食物"
) : (
"更新食物"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}