2026-03-17 13:17:02 +08:00

652 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useEffect, useRef } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, Edit, AlertTriangle, FileText, Play, Pause, Volume2, VolumeX, Music, Download, Factory } from "lucide-react"
import Link from "next/link"
import { AddPrintBatchDialog } from "@/components/songs/add-print-batch-dialog"
import { ExportCardsDialog } from "@/components/songs/export-cards-dialog"
import { ExportBatchDialog } from "@/components/songs/export-batch-dialog"
import { useToast } from "@/components/ui/use-toast"
import { getSong, getSongBatches, markBatchAsProduced, SongBatch } from "@/lib/api/songs"
import { Song } from "@/lib/api/types"
import { Skeleton } from "@/components/ui/skeleton"
import { Slider } from "@/components/ui/slider"
import { useParams } from "next/navigation"
import { format } from "date-fns"
export default function SongDetailPage() {
const params = useParams()
const songId = params.id as string
const { toast } = useToast()
const [song, setSong] = useState<Song | null>(null)
const [batches, setBatches] = useState<SongBatch[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isBatchesLoading, setIsBatchesLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [isAudioLoading, setIsAudioLoading] = useState(false)
const [volume, setVolume] = useState(80)
const [isMuted, setIsMuted] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
// 加载歌曲数据
useEffect(() => {
const fetchSong = async () => {
if (!songId) return
setIsLoading(true)
try {
const songData = await getSong(songId)
setSong(songData)
setError(null)
} catch (error) {
console.error("获取歌曲失败:", error)
setError(error instanceof Error ? error.message : "未知错误")
toast({
title: "获取歌曲失败",
description: error instanceof Error ? error.message : "无法加载歌曲数据,请稍后重试",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchSong()
}, [songId, toast])
// 加载批次数据
useEffect(() => {
const fetchBatches = async () => {
if (!songId) return
setIsBatchesLoading(true)
try {
const batchData = await getSongBatches(songId)
setBatches(batchData)
} catch (error) {
console.error("获取批次数据失败:", error)
toast({
title: "获取批次数据失败",
description: error instanceof Error ? error.message : "无法加载批次数据,请稍后重试",
variant: "destructive",
})
} finally {
setIsBatchesLoading(false)
}
}
fetchBatches()
}, [songId, toast])
// 刷新批次数据的函数
const refreshBatches = async () => {
if (!songId) return
setIsBatchesLoading(true)
try {
const batchData = await getSongBatches(songId)
setBatches(batchData)
} catch (error) {
console.error("刷新批次数据失败:", error)
} finally {
setIsBatchesLoading(false)
}
}
// 初始化音频播放器 - 现在只创建但不自动播放
useEffect(() => {
if (song?.audioUrl && !audioRef.current) {
audioRef.current = new Audio(song.audioUrl)
const audio = audioRef.current
// 音频加载完成时 - 不再自动播放
audio.addEventListener('canplay', () => {
setIsAudioLoading(false)
})
// 音频播放结束时
audio.addEventListener('ended', () => {
setIsPlaying(false)
})
// 音频播放错误时
audio.addEventListener('error', () => {
setIsAudioLoading(false)
setIsPlaying(false)
toast({
title: "播放错误",
description: "无法播放该音频文件",
variant: "destructive",
})
})
// 设置音量
audio.volume = volume / 100
audio.muted = isMuted
}
return () => {
if (audioRef.current) {
const audio = audioRef.current
audio.pause()
audio.src = ''
audio.removeEventListener('canplay', () => {})
audio.removeEventListener('ended', () => {})
audio.removeEventListener('error', () => {})
audioRef.current = null
}
}
}, [song?.audioUrl, toast, volume, isMuted])
// 监听音量变化
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume / 100
}
}, [volume])
// 监听静音状态
useEffect(() => {
if (audioRef.current) {
audioRef.current.muted = isMuted
}
}, [isMuted])
// 处理播放/暂停
const togglePlay = () => {
if (!song?.audioUrl) {
toast({
title: "无法播放",
description: "该歌曲没有可播放的音频文件",
variant: "destructive",
})
return
}
if (isPlaying) {
audioRef.current?.pause()
setIsPlaying(false)
} else {
setIsAudioLoading(true)
if (audioRef.current) {
audioRef.current.play()
.then(() => {
setIsPlaying(true)
setIsAudioLoading(false)
})
.catch((error) => {
console.error('播放失败:', error)
setIsAudioLoading(false)
toast({
title: "播放失败",
description: "无法播放音频,请稍后重试",
variant: "destructive",
})
})
}
}
}
// 切换静音状态
const toggleMute = () => {
setIsMuted(!isMuted)
}
// 处理音量变化
const handleVolumeChange = (values: number[]) => {
const newVolume = values[0]
setVolume(newVolume)
// 如果音量从0调高则取消静音
if (newVolume > 0 && isMuted) {
setIsMuted(false)
}
// 如果音量调到0则设置为静音
else if (newVolume === 0 && !isMuted) {
setIsMuted(true)
}
}
// 标记批次为已生产
const handleMarkAsProduced = async (batchId: number) => {
try {
setIsBatchesLoading(true);
await markBatchAsProduced(batchId);
toast({
title: "操作成功",
description: "批次已成功标记为已生产",
});
await refreshBatches();
} catch (error) {
console.error("标记批次为已生产失败:", error);
toast({
title: "操作失败",
description: error instanceof Error ? error.message : "无法标记批次为已生产,请稍后重试",
variant: "destructive",
});
} finally {
setIsBatchesLoading(false);
}
};
if (error) {
return (
<DashboardShell>
<div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-16 w-16 text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-500 mb-6">ID为 {songId} </p>
<Button asChild>
<Link href="/songs">
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
</DashboardShell>
)
}
if (isLoading || !song) {
return (
<DashboardShell>
<div className="flex items-center mb-6">
<Button variant="ghost" size="sm" className="mr-4" asChild>
<Link href="/songs">
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
<div className="w-full">
<Skeleton className="h-10 w-64 mb-2" />
<Skeleton className="h-5 w-32" />
</div>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Skeleton className="h-[400px]" />
<Skeleton className="h-[400px] md:col-span-2" />
</div>
</DashboardShell>
)
}
const isPublished = song.status === "已发布";
return (
<DashboardShell>
<div className="absolute top-0 right-0 w-1/2 h-48 bg-gradient-to-bl from-pink-200 via-purple-200 to-transparent opacity-20 rounded-bl-full" />
<div className="flex items-center mb-6">
<Button variant="ghost" size="sm" className="mr-4" asChild>
<Link href="/songs">
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
<DashboardHeader heading={song.name} text={`歌曲ID: ${song.id}`}>
<div className="flex space-x-2 ml-auto">
{song.audioUrl && (
<Button
variant="outline"
size="sm"
className={`${
isPlaying
? "bg-pink-100 text-pink-700 hover:bg-pink-200"
: "hover:bg-pink-50 hover:text-pink-700"
}`}
onClick={togglePlay}
disabled={isAudioLoading}
>
{isAudioLoading ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : isPlaying ? (
<>
<Pause className="h-4 w-4 mr-2" />
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
</>
)}
</Button>
)}
{!isPublished && (
<Button
asChild
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"
>
<Link href={`/songs/edit/${songId}`}>
<Edit className="mr-2 h-4 w-4" />
</Link>
</Button>
)}
<ExportCardsDialog songId={song.id} />
</div>
</DashboardHeader>
</div>
{/* 音频控制器 - 仅当歌曲有音频URL且正在播放时显示 */}
{song.audioUrl && isPlaying && (
<div className="mb-4">
<div className="bg-pink-50 p-3 rounded-lg border border-pink-100 flex items-center">
<div className="flex-shrink-0 mr-3">
<Music className="h-5 w-5 text-pink-500" />
</div>
<div className="flex-grow">
<div className="flex items-center space-x-4">
<div className="flex-grow">
<p className="text-sm font-medium text-pink-700 truncate">
: {song.name}
</p>
<p className="text-xs text-pink-500 truncate">
{song.composer}
</p>
</div>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-pink-100"
onClick={toggleMute}
>
{isMuted ? <VolumeX className="h-4 w-4 text-pink-500" /> : <Volume2 className="h-4 w-4 text-pink-500" />}
</Button>
<div className="w-32">
<Slider
value={[volume]}
min={0}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="h-1"
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-pink-200 hover:bg-pink-300 text-pink-700"
onClick={togglePlay}
>
<Pause className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
)}
<Tabs defaultValue="details" className="space-y-4">
<TabsList>
<TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="batches"></TabsTrigger>
<TabsTrigger value="analytics"></TabsTrigger>
</TabsList>
<TabsContent value="details" className="space-y-4">
<div className="grid gap-6 md:grid-cols-3">
<Card className="md:col-span-1 border-none shadow-lg bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<div className="relative w-full aspect-square max-w-[300px] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center group">
<img src={song.image || "/placeholder.svg"} alt={song.name} className="object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<div className="flex flex-wrap gap-2">
<Badge className={`${isPublished ? "bg-green-500" : "bg-gray-500"}`}>{song.status}</Badge>
{song.rarity && <Badge className="bg-purple-500">{song.rarity}</Badge>}
{song.cardType && <Badge className="bg-blue-500">{song.cardType}</Badge>}
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="md:col-span-2 border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.composer}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.lyricist}</p>
</div>
{song.arrangement && (
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.arrangement}</p>
</div>
)}
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.duration}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.releaseDate || "尚未发布"}</p>
</div>
{song.genre && (
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.genre}</p>
</div>
)}
{song.bpm && (
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500">BPM</p>
<p className="font-medium">{song.bpm}</p>
</div>
)}
{song.price && (
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">¥{song.price}</p>
</div>
)}
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.cardsCount || 0}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-500"></p>
<p className="font-medium">{song.batchesCount || 0}</p>
</div>
</div>
{song.description && (
<div className="mt-4">
<p className="text-sm font-medium text-gray-500 mb-1"></p>
<p className="text-sm">{song.description}</p>
</div>
)}
{song.lyrics && (
<div className="mt-4">
<p className="text-sm font-medium text-gray-500 mb-1"></p>
<div className="text-sm max-h-40 overflow-y-auto p-3 bg-gray-50 rounded">
<pre className="whitespace-pre-wrap font-sans">{song.lyrics}</pre>
</div>
</div>
)}
{isPublished && (
<div className="mt-6 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" />
<p className="text-sm text-amber-700"></p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="batches" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-blue-50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>ID</CardDescription>
</div>
<AddPrintBatchDialog songId={song.id} isPublished={isPublished} onBatchCreated={refreshBatches} />
</CardHeader>
<CardContent>
{/* 如果没有批次数据,显示空状态 */}
{isBatchesLoading ? (
<div className="space-y-3">
<div className="flex justify-between mb-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-16" />
))}
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between py-3 border-b">
{Array.from({ length: 6 }).map((_, j) => (
<Skeleton key={j} className="h-4 w-16" />
))}
</div>
))}
</div>
) : batches.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500"></p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b">
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500">ID</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-500"></th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{batches.map((batch) => (
<tr key={batch.id} className="border-b">
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.id}</td>
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{format(new Date(batch.created_at), 'yyyy-MM-dd')}</td>
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.quantity}</td>
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.start_id}</td>
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">{batch.end_id}</td>
<td className="py-3 px-4 text-left text-sm font-medium text-gray-500">
<Badge className={getBatchStatusColor(batch.status)}>
{batch.status_display || getBatchStatusText(batch.status)}
</Badge>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end space-x-2">
<ExportBatchDialog batchId={batch.id} />
{batch.status !== 'produced' && batch.status !== 'published' && (
<Button
variant="ghost"
size="sm"
className="h-8 hover:bg-green-50 hover:text-green-600"
onClick={() => handleMarkAsProduced(batch.id)}
>
<Factory className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics" className="space-y-4">
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-green-50">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
<div className="md:col-span-2 h-[300px] bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500"></p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DashboardShell>
)
}
// 获取批次状态颜色
function getBatchStatusColor(status: string): string {
switch (status) {
case 'draft':
return 'bg-gray-500';
case 'exported':
return 'bg-blue-500';
case 'in_production':
return 'bg-amber-500';
case 'produced':
return 'bg-purple-500';
case 'published':
return 'bg-green-500';
default:
return 'bg-gray-500';
}
}
// 获取批次状态文本
function getBatchStatusText(status: string): string {
switch (status) {
case 'draft':
return '草稿';
case 'exported':
return '已导出';
case 'in_production':
return '生产中';
case 'produced':
return '已生产';
case 'published':
return '已发布';
default:
return status;
}
}