lty/qy-lty-admin/components/outfits/add-outfit-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

389 lines
13 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, Trash2 } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { FileUpload } from "@/components/ui/file-upload"
import { createOutfit, updateOutfit } from "@/lib/api/outfits"
function isRealImageUrl(url?: string): boolean {
if (!url) return false
return !url.includes("placeholder")
}
const RARITY_MAP: Record<string, string> = {
"普通": "common",
"稀有": "rare",
"史诗": "epic",
"传说": "legendary",
}
export type DisplayOutfit = {
id: string
name: string
type: string
rarity: string
description: string
releaseDate: string
status: string
activatedCount: number
image?: string
}
type AddOutfitDialogProps = {
mode?: "create" | "edit"
initialOutfit?: DisplayOutfit
open?: boolean
onOpenChange?: (open: boolean) => void
onSave?: (outfit: DisplayOutfit) => void
}
export function AddOutfitDialog({
mode = "create",
initialOutfit,
open: controlledOpen,
onOpenChange: setControlledOpen,
onSave,
}: AddOutfitDialogProps) {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState("")
const [outfitType, setOutfitType] = useState("")
const [rarity, setRarity] = useState("")
const [description, setDescription] = useState("")
const [isLimited, setIsLimited] = useState(false)
const [imageUrl, setImageUrl] = useState<string | undefined>()
const [previewId, setPreviewId] = useState(
"OFT" +
Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0"),
)
useEffect(() => {
if (mode === "edit" && initialOutfit) {
setName(initialOutfit.name)
setOutfitType(initialOutfit.type)
setRarity(initialOutfit.rarity)
setDescription(initialOutfit.description)
setIsLimited(initialOutfit.type === "限定服装")
setPreviewId(initialOutfit.id)
setImageUrl(isRealImageUrl(initialOutfit.image) ? initialOutfit.image : undefined)
}
}, [mode, initialOutfit])
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("")
setOutfitType("")
setRarity("")
setDescription("")
setIsLimited(false)
setImageUrl(undefined)
setPreviewId(
"OFT" +
Math.floor(Math.random() * 1000)
.toString()
.padStart(3, "0"),
)
}
}
const handleSubmit = async () => {
if (!name || !outfitType || !rarity || !description) {
alert("请填写所有必填字段!")
return
}
setIsSubmitting(true)
try {
const actualType = isLimited ? "限定服装" : outfitType
if (mode === "create") {
const created = await createOutfit({
name,
description,
category: actualType,
rarityValue: RARITY_MAP[rarity] || rarity,
imageUrl,
})
const outfit: DisplayOutfit = {
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 || "",
}
if (onSave) {
onSave(outfit)
}
} else {
const updated = await updateOutfit(initialOutfit!.id, {
name,
description,
category: actualType,
imageUrl,
})
const outfit: DisplayOutfit = {
id: updated.id,
name: updated.name,
type: updated.category || actualType,
rarity: updated.rarity || rarity,
description: updated.description || description,
releaseDate: initialOutfit?.releaseDate || "",
status: updated.status || initialOutfit?.status || "未发布",
activatedCount: updated.activeCardsCount || initialOutfit?.activatedCount || 0,
image: updated.imageUrl || initialOutfit?.image || "",
}
if (onSave) {
onSave(outfit)
}
}
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={outfitType} onValueChange={setOutfitType} 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>
<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">
<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>
)
}