- 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>
394 lines
14 KiB
TypeScript
394 lines
14 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 type { HomeDecor } from "./home-decor-detail-dialog"
|
||
import { createHomeDecor, updateHomeDecor } from "@/lib/api/home-decor"
|
||
|
||
// 判断是否为有效图片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 AddHomeDecorDialogProps = {
|
||
mode?: "create" | "edit"
|
||
initialDecor?: HomeDecor
|
||
open?: boolean
|
||
onOpenChange?: (open: boolean) => void
|
||
onSave?: (decor: HomeDecor) => void
|
||
}
|
||
|
||
export function AddHomeDecorDialog({
|
||
mode = "create",
|
||
initialDecor,
|
||
open: controlledOpen,
|
||
onOpenChange: setControlledOpen,
|
||
onSave,
|
||
}: AddHomeDecorDialogProps) {
|
||
const [open, setOpen] = useState(false)
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
|
||
// 表单状态
|
||
const [name, setName] = useState("")
|
||
const [decorType, setDecorType] = useState("")
|
||
const [rarity, setRarity] = useState("")
|
||
const [description, setDescription] = useState("")
|
||
const [isLimited, setIsLimited] = useState(false)
|
||
const [imageUrl, setImageUrl] = useState<string | undefined>()
|
||
const [previewId, setPreviewId] = useState(
|
||
"DEC" +
|
||
Math.floor(Math.random() * 1000)
|
||
.toString()
|
||
.padStart(3, "0"),
|
||
)
|
||
|
||
// 当编辑模式且有装饰数据时,初始化表单
|
||
useEffect(() => {
|
||
if (mode === "edit" && initialDecor) {
|
||
setName(initialDecor.name)
|
||
setDecorType(initialDecor.type)
|
||
setRarity(initialDecor.rarity)
|
||
setDescription(initialDecor.description)
|
||
setIsLimited(initialDecor.type.includes("限定"))
|
||
setPreviewId(initialDecor.id)
|
||
setImageUrl(isRealImageUrl(initialDecor.image) ? initialDecor.image : undefined)
|
||
}
|
||
}, [mode, initialDecor])
|
||
|
||
// 处理受控和非受控开关状态
|
||
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("")
|
||
setDecorType("")
|
||
setRarity("")
|
||
setDescription("")
|
||
setIsLimited(false)
|
||
setImageUrl(undefined)
|
||
setPreviewId(
|
||
"DEC" +
|
||
Math.floor(Math.random() * 1000)
|
||
.toString()
|
||
.padStart(3, "0"),
|
||
)
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
// 表单验证
|
||
if (!name || !decorType || !rarity || !description) {
|
||
alert("请填写所有必填字段!")
|
||
return
|
||
}
|
||
|
||
setIsSubmitting(true)
|
||
try {
|
||
const actualType = isLimited ? `限定${decorType}` : decorType
|
||
|
||
if (mode === "create") {
|
||
// 调用后端创建 API
|
||
const created = await createHomeDecor({
|
||
name,
|
||
description,
|
||
category: actualType,
|
||
rarityValue: RARITY_MAP[rarity] || rarity,
|
||
imageUrl,
|
||
})
|
||
|
||
// 构建组件格式的 HomeDecor 用于回调
|
||
const decor: HomeDecor = {
|
||
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(decor)
|
||
}
|
||
} else {
|
||
// 调用后端更新 API
|
||
const updated = await updateHomeDecor(initialDecor!.id, {
|
||
name,
|
||
description,
|
||
category: actualType,
|
||
imageUrl,
|
||
})
|
||
|
||
const decor: HomeDecor = {
|
||
id: updated.id,
|
||
name: updated.name,
|
||
type: updated.category || actualType,
|
||
rarity: updated.rarity || rarity,
|
||
description: updated.description || description,
|
||
releaseDate: initialDecor?.releaseDate || "",
|
||
status: updated.status || initialDecor?.status || "未发布",
|
||
activatedCount: updated.activeCardsCount || initialDecor?.activatedCount || 0,
|
||
image: updated.imageUrl || initialDecor?.image || "/placeholder.svg?height=300&width=300",
|
||
}
|
||
|
||
if (onSave) {
|
||
onSave(decor)
|
||
}
|
||
}
|
||
|
||
// 关闭对话框
|
||
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={decorType} onValueChange={setDecorType} 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>
|
||
)
|
||
}
|