- 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>
389 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|