lty/qy-lty-admin/components/props/add-prop-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

391 lines
14 KiB
TypeScript
Raw 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, Trash2 } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { FileUpload } from "@/components/ui/file-upload"
import type { Prop } from "./prop-detail-dialog"
import { createProp, updateProp } from "@/lib/api/props"
// 判断是否为有效图片URL非placeholder
function isRealImageUrl(url?: string): boolean {
if (!url) return false
return !url.includes("placeholder")
}
// 中文稀有度 -> 后端 rarity key
const RARITY_MAP: Record<string, string> = {
"普通": "common",
"稀有": "rare",
"史诗": "epic",
"传说": "legendary",
}
type AddPropDialogProps = {
mode?: "create" | "edit"
initialProp?: Prop
open?: boolean
onOpenChange?: (open: boolean) => void
onSave?: (prop: Prop) => void
}
export function AddPropDialog({
mode = "create",
initialProp,
open: controlledOpen,
onOpenChange: setControlledOpen,
onSave,
}: AddPropDialogProps) {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// 表单状态
const [name, setName] = useState("")
const [propType, setPropType] = useState("")
const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState(
"PRP" +
Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0"),
)
// 当编辑模式且有道具数据时,初始化表单
useEffect(() => {
if (mode === "edit" && initialProp) {
setName(initialProp.name)
setPropType(initialProp.type)
setRarity(initialProp.rarity)
setDescription(initialProp.description)
setIsLimited(initialProp.type === "限定道具")
setPreviewId(initialProp.id)
setImageUrl(isRealImageUrl(initialProp.image) ? initialProp.image : undefined)
}
}, [mode, initialProp])
// 处理受控和非受控开关状态
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("")
setPropType("")
setRarity("")
setDescription("")
setIsLimited(false)
setImageUrl(undefined)
setPreviewId(
"PRP" +
Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0"),
)
}
}
const handleSubmit = async () => {
// 表单验证
if (!name || !propType || !rarity || !description) {
alert("请填写所有必填字段!")
return
}
setIsSubmitting(true)
try {
const actualType = isLimited ? "限定道具" : propType
if (mode === "create") {
// 调用后端创建 API
const created = await createProp({
name,
description,
category: actualType,
rarityValue: RARITY_MAP[rarity] || rarity,
imageUrl,
})
// 构建组件格式的 Prop 用于回调
const prop: Prop = {
id: created.id,
name: created.name,
type: created.category || actualType,
rarity: created.rarity || rarity,
description: created.description || description,
releaseDate: created.createdAt || "",
status: created.status || "未发布",
activatedCount: created.activeCardsCount || 0,
image: created.imageUrl || "/placeholder.svg?height=300&width=300",
}
if (onSave) {
onSave(prop)
}
} else {
// 调用后端更新 API
const updated = await updateProp(initialProp!.id, {
name,
description,
category: actualType,
imageUrl,
})
const prop: Prop = {
id: updated.id,
name: updated.name,
type: updated.category || actualType,
rarity: updated.rarity || rarity,
description: updated.description || description,
releaseDate: initialProp?.releaseDate || "",
status: updated.status || initialProp?.status || "未发布",
activatedCount: updated.activeCardsCount || initialProp?.activatedCount || 0,
image: updated.imageUrl || initialProp?.image || "/placeholder.svg?height=300&width=300",
}
if (onSave) {
onSave(prop)
}
}
// 关闭对话框
handleOpenChange(false)
} catch (error) {
console.error(mode === "create" ? "创建道具失败:" : "更新道具失败:", error)
alert(mode === "create" ? "创建道具失败,请重试!" : "更新道具失败,请重试!")
} 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]">
<DialogHeader>
<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 py-4">
<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={propType} onValueChange={setPropType} required>
<SelectTrigger className="border-gray-300 focus:ring-pink-500">
<SelectValue placeholder="选择道具类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="演出道具"></SelectItem>
<SelectItem value="互动道具"></SelectItem>
<SelectItem value="收藏品"></SelectItem>
<SelectItem value="限定道具"></SelectItem>
<SelectItem value="其他"></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="普通"></SelectItem>
<SelectItem value="稀有"></SelectItem>
<SelectItem value="史诗"></SelectItem>
<SelectItem value="传说"></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">
<EFBFBD><EFBFBD><EFBFBD> <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="space-y-2">
<Label className="text-right">
{mode === "create" && <span className="text-red-500">*</span>}
</Label>
{imageUrl ? (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center space-x-3">
<img
src={imageUrl}
alt="道具图片"
className="h-16 w-16 object-cover rounded border"
/>
<span className="text-sm text-gray-700"></span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setImageUrl(undefined)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<FileUpload
imageOnly={true}
multiple={false}
maxFiles={1}
maxSize={5 * 1024 * 1024}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] }}
placeholder="点击或拖拽上传道具图片"
disabled={isSubmitting}
onUploadSuccess={(files) => {
if (files.length > 0) {
setImageUrl(files[0].url)
}
}}
/>
)}
</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>
<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>
)
}