- 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>
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
"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>
|
||
)
|
||
}
|