338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } 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, Upload, Loader2, Music } from "lucide-react"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import type { Song } from "./song-detail-dialog"
|
|
import { uploadSongFile, createSong, updateSong } from "@/lib/api/songs"
|
|
|
|
type AddSongDialogProps = {
|
|
mode?: "create" | "edit"
|
|
initialSong?: Song
|
|
open?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
onSave?: (song: Song) => void
|
|
}
|
|
|
|
export function AddSongDialog({
|
|
mode = "create",
|
|
initialSong,
|
|
open: controlledOpen,
|
|
onOpenChange: setControlledOpen,
|
|
onSave,
|
|
}: AddSongDialogProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
// 表单状态
|
|
const [name, setName] = useState("")
|
|
const [songType, setSongType] = useState("")
|
|
const [composer, setComposer] = useState("")
|
|
const [lyricist, setLyricist] = useState("")
|
|
const [duration, setDuration] = useState("")
|
|
const [bpm, setBpm] = useState("")
|
|
const [description, setDescription] = useState("")
|
|
const [isOriginal, setIsOriginal] = useState(true)
|
|
const [previewId, setPreviewId] = useState(
|
|
"SNG" +
|
|
Math.floor(Math.random() * 1000)
|
|
.toString()
|
|
.padStart(3, "0"),
|
|
)
|
|
const [audioFile, setAudioFile] = useState<File | null>(null)
|
|
const [audioUrl, setAudioUrl] = useState<string>("")
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
const [uploadError, setUploadError] = useState<string>("")
|
|
|
|
const audioInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// 当编辑模式且有歌曲数据时,初始化表单
|
|
useEffect(() => {
|
|
if (mode === "edit" && initialSong) {
|
|
setName(initialSong.name)
|
|
setComposer(initialSong.composer)
|
|
setLyricist(initialSong.lyricist)
|
|
setDuration(initialSong.duration)
|
|
setIsOriginal(true) // 假设所有编辑的歌曲都是原创
|
|
setPreviewId(initialSong.id)
|
|
|
|
// 初始化歌曲类型
|
|
if (initialSong.genre) {
|
|
setSongType(initialSong.genre)
|
|
}
|
|
|
|
// 初始化BPM
|
|
if (initialSong.bpm) {
|
|
setBpm(String(initialSong.bpm))
|
|
}
|
|
|
|
// 初始化描述
|
|
if (initialSong.description) {
|
|
setDescription(initialSong.description)
|
|
}
|
|
|
|
// 初始化音频URL
|
|
if (initialSong.audioUrl) {
|
|
setAudioUrl(initialSong.audioUrl)
|
|
}
|
|
}
|
|
}, [mode, initialSong])
|
|
|
|
// 处理受控和非受控开关状态
|
|
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("")
|
|
setSongType("")
|
|
setComposer("")
|
|
setLyricist("")
|
|
setDuration("")
|
|
setBpm("")
|
|
setDescription("")
|
|
setIsOriginal(true)
|
|
setPreviewId(
|
|
"SNG" +
|
|
Math.floor(Math.random() * 1000)
|
|
.toString()
|
|
.padStart(3, "0"),
|
|
)
|
|
}
|
|
}
|
|
|
|
// 歌曲文件选择和上传
|
|
const handleAudioFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setAudioFile(file)
|
|
setIsUploading(true)
|
|
setUploadError("")
|
|
try {
|
|
const res = await uploadSongFile({ file, isPermanent: true, filename: file.name })
|
|
setAudioUrl(res.url)
|
|
} catch (err: any) {
|
|
setUploadError(err.message || "上传失败")
|
|
setAudioUrl("")
|
|
} finally {
|
|
setIsUploading(false)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
// 表单验证
|
|
if (!name || !composer || !lyricist || !duration) {
|
|
alert("请填写所有必填字段!")
|
|
return
|
|
}
|
|
|
|
// 创建模式下必须上传音频
|
|
if (mode === "create" && !audioUrl) {
|
|
alert("请上传歌曲文件!")
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
// 构造基本的请求数据
|
|
const songAttributes: any = {
|
|
genre: songType || undefined,
|
|
duration,
|
|
composer,
|
|
lyricist,
|
|
arrangement: isOriginal ? undefined : "",
|
|
lyrics: description || undefined,
|
|
}
|
|
|
|
// 只有在有音频URL时才添加
|
|
if (audioUrl) {
|
|
songAttributes.audio_file = encodeURI(audioUrl)
|
|
}
|
|
|
|
// 只有在有BPM时才添加
|
|
if (bpm) {
|
|
songAttributes.bpm = Number(bpm)
|
|
}
|
|
|
|
if (mode === "create") {
|
|
// 创建模式
|
|
const payload = {
|
|
name,
|
|
category: "song",
|
|
card_type: "regular",
|
|
rarity: "epic",
|
|
price: 39.99,
|
|
status: "draft",
|
|
description: description || undefined,
|
|
song_attributes: songAttributes
|
|
}
|
|
const created = await createSong(payload)
|
|
if (onSave) onSave(created)
|
|
} else if (mode === "edit" && initialSong) {
|
|
// 编辑模式
|
|
const payload = {
|
|
name,
|
|
category: "song",
|
|
song_attributes: songAttributes
|
|
}
|
|
const updated = await updateSong(initialSong.id, payload)
|
|
if (onSave) onSave(updated)
|
|
}
|
|
|
|
handleOpenChange(false)
|
|
} catch (error: any) {
|
|
alert(error.message || (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-[520px] p-4">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600">
|
|
{mode === "create" ? "添加新歌曲" : "编辑歌曲"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs mt-1 mb-2">{mode === "create" ? "填写歌曲信息以创建新的歌曲。" : "修改歌曲信息。"}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2 py-2">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="name" className="text-xs">歌曲名称 <span className="text-red-500">*</span></Label>
|
|
<Input id="name" placeholder="输入歌曲名称" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={name} onChange={(e) => setName(e.target.value)} required />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="type" className="text-xs">歌曲类型</Label>
|
|
<Select value={songType} onValueChange={setSongType}>
|
|
<SelectTrigger className="h-8 text-xs border-gray-300 focus:ring-pink-500">
|
|
<SelectValue placeholder="选择歌曲类型" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="pop">流行</SelectItem>
|
|
<SelectItem value="rock">摇滚</SelectItem>
|
|
<SelectItem value="electronic">电子</SelectItem>
|
|
<SelectItem value="folk">民谣</SelectItem>
|
|
<SelectItem value="classical">古典</SelectItem>
|
|
<SelectItem value="other">其他</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="composer" className="text-xs">作曲 <span className="text-red-500">*</span></Label>
|
|
<Input id="composer" placeholder="输入作曲者" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={composer} onChange={(e) => setComposer(e.target.value)} required />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="lyricist" className="text-xs">作词 <span className="text-red-500">*</span></Label>
|
|
<Input id="lyricist" placeholder="输入作词者" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={lyricist} onChange={(e) => setLyricist(e.target.value)} required />
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="duration" className="text-xs">时长 <span className="text-red-500">*</span></Label>
|
|
<Input id="duration" placeholder="格式: MM:SS" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={duration} onChange={(e) => setDuration(e.target.value)} required />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="bpm" className="text-xs">BPM</Label>
|
|
<Input id="bpm" type="number" placeholder="输入BPM" className="h-8 text-xs border-gray-300 focus-visible:ring-pink-500" value={bpm} onChange={(e) => setBpm(e.target.value)} />
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">歌曲文件 {mode === "create" && <span className="text-red-500">*</span>}</Label>
|
|
<div
|
|
className="relative border-2 border-dashed border-gray-300 rounded-md p-2 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer min-h-[60px]"
|
|
onClick={() => !isUploading && !isSubmitting && audioInputRef.current?.click()}
|
|
style={{ position: 'relative' }}
|
|
>
|
|
<Music className="h-5 w-5 text-gray-400 mb-1" />
|
|
<span className="text-xs text-gray-500">{audioFile ? audioFile.name : "上传"}</span>
|
|
<input
|
|
ref={audioInputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
className="hidden"
|
|
onChange={handleAudioFileChange}
|
|
disabled={isUploading || isSubmitting}
|
|
/>
|
|
{isUploading && <span className="text-xs text-pink-500 mt-1">上传中...</span>}
|
|
{audioUrl && !isUploading && <span className="text-xs text-green-600 mt-1">已上传</span>}
|
|
{uploadError && <span className="text-xs text-red-500 mt-1">{uploadError}</span>}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">歌曲封面</Label>
|
|
<div className="border-2 border-dashed border-gray-300 rounded-md p-2 flex flex-col items-center justify-center hover:border-pink-500 transition-colors cursor-pointer min-h-[60px]">
|
|
<Upload className="h-5 w-5 text-gray-400 mb-1" />
|
|
<span className="text-xs text-gray-500">上传</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 可折叠描述和预览信息 */}
|
|
<div className="mt-1">
|
|
<details className="mb-1">
|
|
<summary className="cursor-pointer text-xs text-gray-600 select-none">歌曲描述</summary>
|
|
<Textarea id="description" placeholder="输入歌曲描述" className="min-h-[60px] text-xs border-gray-300 focus-visible:ring-pink-500 mt-1" value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
</details>
|
|
{mode === "create" && (
|
|
<details>
|
|
<summary className="cursor-pointer text-xs text-gray-600 select-none">预览信息</summary>
|
|
<div className="p-2 bg-gray-50 rounded mt-1">
|
|
<p className="text-xs text-gray-600">歌曲ID: <span className="font-medium text-pink-600">{previewId}</span></p>
|
|
<p className="text-xs text-gray-600 mt-1">状态: <span className="font-medium">未发布</span></p>
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center space-x-2 pt-1 pb-2">
|
|
<Switch id="original" checked={isOriginal} onCheckedChange={setIsOriginal} />
|
|
<Label htmlFor="original" className="cursor-pointer text-xs">这是原创歌曲</Label>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting} className="h-8 px-4 text-xs">取消</Button>
|
|
<Button className="h-8 px-4 text-xs 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>
|
|
)
|
|
}
|