- 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>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import type { Dance } from "@/lib/api/types"
|
|
import { useToast } from "@/hooks/use-toast"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
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 { Loader2, Trash2 } from "lucide-react"
|
|
import { FileUpload } from "@/components/ui/file-upload"
|
|
|
|
interface AddDanceDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onDanceAdded: (dance: Dance) => void
|
|
editDance?: Dance
|
|
}
|
|
|
|
export function AddDanceDialog({ open, onOpenChange, onDanceAdded, editDance }: AddDanceDialogProps) {
|
|
const { toast } = useToast()
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
// 表单状态
|
|
const [formData, setFormData] = useState<Partial<Dance>>({
|
|
name: "",
|
|
choreographer: "",
|
|
duration: "",
|
|
difficulty: "中等",
|
|
description: "",
|
|
category: "流行",
|
|
tags: [],
|
|
motionFile: "",
|
|
videoUrl: "",
|
|
coverUrl: "",
|
|
})
|
|
|
|
// 预览ID
|
|
const [previewId, setPreviewId] = useState(
|
|
"DNC" +
|
|
Math.floor(Math.random() * 1000)
|
|
.toString()
|
|
.padStart(3, "0"),
|
|
)
|
|
|
|
// 当编辑模式且有舞蹈数据时,初始化表单
|
|
useEffect(() => {
|
|
if (editDance) {
|
|
setFormData({
|
|
name: editDance.name || "",
|
|
choreographer: editDance.choreographer || "",
|
|
duration: editDance.duration || "",
|
|
difficulty: editDance.difficulty || "中等",
|
|
description: editDance.description || "",
|
|
category: editDance.category || "流行",
|
|
tags: editDance.tags || [],
|
|
motionFile: editDance.motionFile || "",
|
|
videoUrl: editDance.videoUrl || "",
|
|
coverUrl: editDance.coverUrl || "",
|
|
})
|
|
} else {
|
|
// 重置表单
|
|
setFormData({
|
|
name: "",
|
|
choreographer: "",
|
|
duration: "",
|
|
difficulty: "中等",
|
|
description: "",
|
|
category: "流行",
|
|
tags: [],
|
|
motionFile: "",
|
|
videoUrl: "",
|
|
coverUrl: "",
|
|
})
|
|
// 生成新的预览ID
|
|
setPreviewId(
|
|
"DNC" +
|
|
Math.floor(Math.random() * 1000)
|
|
.toString()
|
|
.padStart(3, "0"),
|
|
)
|
|
}
|
|
}, [editDance, open])
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const { name, value } = e.target
|
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
}
|
|
|
|
const handleSelectChange = (name: string, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
}
|
|
|
|
const handleTagsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const tagsString = e.target.value
|
|
const tagsArray = tagsString
|
|
.split(",")
|
|
.map((tag) => tag.trim())
|
|
.filter((tag) => tag !== "")
|
|
setFormData((prev) => ({ ...prev, tags: tagsArray }))
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
// 表单验证
|
|
if (!formData.name) {
|
|
toast({
|
|
title: "表单不完整",
|
|
description: "请填写舞蹈名称",
|
|
variant: "destructive",
|
|
})
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
// 模拟API请求延迟
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
|
const newDance: Dance = {
|
|
id: editDance?.id || previewId,
|
|
name: formData.name || "",
|
|
choreographer: formData.choreographer || "",
|
|
duration: formData.duration || "",
|
|
difficulty: formData.difficulty || "中等",
|
|
description: formData.description || "",
|
|
category: formData.category || "流行",
|
|
tags: formData.tags || [],
|
|
motionFile: formData.motionFile || "",
|
|
videoUrl: formData.videoUrl || "",
|
|
coverUrl: formData.coverUrl || editDance?.coverUrl || "/placeholder.svg?height=300&width=400",
|
|
createdAt: editDance?.createdAt || new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
|
|
onDanceAdded(newDance)
|
|
onOpenChange(false)
|
|
} catch (error) {
|
|
toast({
|
|
title: "操作失败",
|
|
description: "添加或更新舞蹈时出现错误,请重试。",
|
|
variant: "destructive",
|
|
})
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
|
{editDance ? "编辑舞蹈" : "添加新舞蹈"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{editDance ? "修改舞蹈信息,完成后点击保存。" : "填写舞蹈信息,完成后点击添加。"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">
|
|
舞蹈名称 <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
value={formData.name || ""}
|
|
onChange={handleChange}
|
|
className="border-gray-300 focus-visible:ring-purple-500"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="choreographer">编舞者</Label>
|
|
<Input
|
|
id="choreographer"
|
|
name="choreographer"
|
|
value={formData.choreographer || ""}
|
|
onChange={handleChange}
|
|
className="border-gray-300 focus-visible:ring-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="duration">时长</Label>
|
|
<Input
|
|
id="duration"
|
|
name="duration"
|
|
value={formData.duration || ""}
|
|
onChange={handleChange}
|
|
placeholder="例如: 3:45"
|
|
className="border-gray-300 focus-visible:ring-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="difficulty">难度</Label>
|
|
<Select
|
|
value={formData.difficulty || "中等"}
|
|
onValueChange={(value) => handleSelectChange("difficulty", value)}
|
|
>
|
|
<SelectTrigger className="border-gray-300 focus:ring-purple-500">
|
|
<SelectValue placeholder="选择难度" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="初级">初级</SelectItem>
|
|
<SelectItem value="中等">中等</SelectItem>
|
|
<SelectItem value="中高级">中高级</SelectItem>
|
|
<SelectItem value="高级">高级</SelectItem>
|
|
<SelectItem value="专业">专业</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="category">分类</Label>
|
|
<Select
|
|
value={formData.category || "流行"}
|
|
onValueChange={(value) => handleSelectChange("category", value)}
|
|
>
|
|
<SelectTrigger className="border-gray-300 focus:ring-purple-500">
|
|
<SelectValue placeholder="选择分类" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="流行">流行</SelectItem>
|
|
<SelectItem value="中国风">中国风</SelectItem>
|
|
<SelectItem value="日式">日式</SelectItem>
|
|
<SelectItem value="现代">现代</SelectItem>
|
|
<SelectItem value="古典">古典</SelectItem>
|
|
<SelectItem value="街舞">街舞</SelectItem>
|
|
<SelectItem value="其他">其他</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tags">标签</Label>
|
|
<Input
|
|
id="tags"
|
|
name="tags"
|
|
value={formData.tags?.join(", ") || ""}
|
|
onChange={handleTagsChange}
|
|
placeholder="用逗号分隔多个标签"
|
|
className="border-gray-300 focus-visible:ring-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="motionFile">动作文件</Label>
|
|
<Input
|
|
id="motionFile"
|
|
name="motionFile"
|
|
value={formData.motionFile || ""}
|
|
onChange={handleChange}
|
|
placeholder="动作文件名称,例如: dance_motion.fbx"
|
|
className="border-gray-300 focus-visible:ring-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="videoUrl">视频链接</Label>
|
|
<Input
|
|
id="videoUrl"
|
|
name="videoUrl"
|
|
value={formData.videoUrl || ""}
|
|
onChange={handleChange}
|
|
placeholder="舞蹈视频链接"
|
|
className="border-gray-300 focus-visible:ring-purple-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">舞蹈描述</Label>
|
|
<Textarea
|
|
id="description"
|
|
name="description"
|
|
value={formData.description || ""}
|
|
onChange={handleChange}
|
|
rows={4}
|
|
placeholder="请输入舞蹈的详细描述..."
|
|
className="min-h-[100px] border-gray-300 focus-visible:ring-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-right">舞蹈封面</Label>
|
|
{formData.coverUrl && !formData.coverUrl.includes('placeholder') ? (
|
|
<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={formData.coverUrl}
|
|
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={() => setFormData((prev) => ({ ...prev, coverUrl: "" }))}
|
|
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) {
|
|
setFormData((prev) => ({ ...prev, coverUrl: files[0].url }))
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-right">动作文件上传</Label>
|
|
{formData.motionFile ? (
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="h-10 w-10 bg-purple-100 rounded border flex items-center justify-center">
|
|
<span className="text-xs font-medium text-purple-600">FBX</span>
|
|
</div>
|
|
<span className="text-sm text-gray-700">当前动作文件</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setFormData((prev) => ({ ...prev, motionFile: "" }))}
|
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
|
disabled={isSubmitting}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<FileUpload
|
|
imageOnly={false}
|
|
multiple={false}
|
|
maxFiles={1}
|
|
maxSize={20 * 1024 * 1024}
|
|
accept={{ 'application/octet-stream': ['.fbx', '.bvh'] }}
|
|
placeholder="点击或拖拽上传动作文件"
|
|
disabled={isSubmitting}
|
|
onUploadSuccess={(files) => {
|
|
if (files.length > 0) {
|
|
setFormData((prev) => ({ ...prev, motionFile: files[0].url }))
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{!editDance && (
|
|
<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-purple-600">{previewId}</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{editDance ? "更新中..." : "创建中..."}
|
|
</>
|
|
) : editDance ? (
|
|
"保存修改"
|
|
) : (
|
|
"添加舞蹈"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|