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

599 lines
21 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 { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Play, Pause, Eye, Volume2, VolumeX, Send } from "lucide-react"
import { AddSongDialog } from "@/components/songs/add-song-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast"
import Link from "next/link"
import { getSongs, publishSong } from "@/lib/api/songs"
import type { Song } from "@/lib/api/types"
import type { Song as ComponentSong } from "@/components/songs/song-detail-dialog"
import { apiSongToComponentSong, componentSongToApiSong } from "@/lib/api/adapters"
import { Slider } from "@/components/ui/slider"
export default function SongsPage() {
const { toast } = useToast()
// 存储API返回的完整歌曲数据包括原始数据结构便于后续更新操作
const [songs, setSongs] = useState<Song[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [selectedSong, setSelectedSong] = useState<ComponentSong | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [playingSongId, setPlayingSongId] = useState<string | null>(null)
const [totalSongs, setTotalSongs] = useState(0)
const [totalPages, setTotalPages] = useState(1)
const [volume, setVolume] = useState(80)
const [isMuted, setIsMuted] = useState(false)
const [isAudioLoading, setIsAudioLoading] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// 音频播放器引用
const audioRef = useRef<HTMLAudioElement | null>(null)
const currentSongUrlRef = useRef<string | null>(null)
const itemsPerPage = 5
// 加载歌曲数据
useEffect(() => {
const fetchSongs = async () => {
setIsLoading(true)
try {
const response = await getSongs({
page: currentPage,
pageSize: itemsPerPage,
search: searchTerm,
})
// 直接使用API返回的完整歌曲数据
// 但确保所有必需属性都有默认值
const songItems = response.items.map(song => ({
...song,
id: song.id || "",
name: song.name || "",
composer: song.composer || "",
lyricist: song.lyricist || "",
duration: song.duration || "",
status: song.status || "未知"
}))
setSongs(songItems)
setTotalSongs(response.total)
setTotalPages(response.totalPages)
} catch (error) {
console.error("获取歌曲失败:", error)
toast({
title: "获取歌曲失败",
description: "无法加载歌曲数据,请稍后重试",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchSongs()
}, [currentPage, searchTerm, toast, refreshTrigger])
// 创建音频元素
useEffect(() => {
audioRef.current = new Audio()
// 设置音频事件监听器
const audio = audioRef.current
// 音频加载完成时
audio.addEventListener('canplay', () => {
setIsAudioLoading(false)
audio.play().catch(err => {
console.error("播放失败:", err)
toast({
title: "播放失败",
description: "无法播放音频,请稍后重试",
variant: "destructive",
})
setPlayingSongId(null)
})
})
// 音频播放结束时
audio.addEventListener('ended', () => {
setPlayingSongId(null)
})
// 音频播放错误时
audio.addEventListener('error', () => {
setIsAudioLoading(false)
setPlayingSongId(null)
toast({
title: "播放错误",
description: "无法播放该音频文件",
variant: "destructive",
})
})
// 清理函数
return () => {
if (audio) {
audio.pause()
audio.src = ''
audio.removeEventListener('canplay', () => {})
audio.removeEventListener('ended', () => {})
audio.removeEventListener('error', () => {})
}
}
}, [toast])
// 监听音量变化
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume / 100
}
}, [volume])
// 监听静音状态变化
useEffect(() => {
if (audioRef.current) {
audioRef.current.muted = isMuted
}
}, [isMuted])
// 处理添加歌曲
const handleAddSong = (newSong: ComponentSong) => {
// 转换为API Song类型
const apiSong = componentSongToApiSong(newSong)
setSongs((prevSongs) => [...prevSongs, apiSong])
setRefreshTrigger(prev => prev + 1) // 触发刷新
toast({
title: "添加成功",
description: `歌曲 ${newSong.name} 已成功添加`,
})
}
// 处理编辑歌曲
const handleEditSong = (updatedSong: ComponentSong) => {
console.log('保存编辑后的歌曲', updatedSong);
// 找到原始歌曲
const originalSong = songs.find(s => s.id === updatedSong.id);
console.log('原始歌曲数据', originalSong);
if (!originalSong) {
console.error('找不到原始歌曲数据');
return;
}
// 转换为API Song类型并保留原始数据
const apiSong = componentSongToApiSong(updatedSong, originalSong);
console.log('转换后的API歌曲数据', apiSong);
// 更新本地歌曲数据
setSongs((prevSongs) => prevSongs.map((song) => {
if (song.id === apiSong.id) {
return apiSong;
}
return song;
}));
setSelectedSong(null);
setIsEditDialogOpen(false);
setRefreshTrigger(prev => prev + 1); // 触发刷新
toast({
title: "更新成功",
description: `歌曲 ${updatedSong.name} 已成功更新`,
});
}
// 处理删除歌曲
const handleDeleteSong = async (songId: string) => {
// 如果正在播放该歌曲,先停止播放
if (playingSongId === songId && audioRef.current) {
audioRef.current.pause()
setPlayingSongId(null)
}
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1000))
setSongs((prevSongs) => prevSongs.filter((song) => song.id !== songId))
setRefreshTrigger(prev => prev + 1) // 触发刷新
toast({
title: "删除成功",
description: "歌曲已成功删除",
variant: "destructive",
})
}
// 打开编辑对话框
const openEditDialog = (song: Song) => {
console.log('编辑歌曲', song);
// 确保从rawData中获取最新的数据
if (song.rawData) {
console.log('使用完整原始数据', song.rawData);
// 转换为组件所需的Song类型优先使用rawData中的完整数据
const attributes = song.rawData.attributes || {};
const componentSong: ComponentSong = {
id: song.id,
name: song.name,
composer: attributes.composer || song.composer || "",
lyricist: attributes.lyricist || song.lyricist || "",
duration: attributes.duration || song.duration || "",
releaseDate: song.releaseDate,
status: song.rawData.status_display || song.status || "",
image: song.rawData.image_url || song.image,
audioUrl: attributes.audio_file || song.audioUrl || "",
description: song.rawData.description || song.description || "",
rarity: song.rawData.rarity_display || song.rarity || "",
cardType: song.rawData.card_type_display || song.cardType || "",
genre: attributes.genre || song.genre || "",
lyrics: attributes.lyrics || song.lyrics || "",
bpm: attributes.bpm || song.bpm || 0
};
setSelectedSong(componentSong);
} else {
// 使用适配器功能
const componentSong = apiSongToComponentSong(song);
setSelectedSong(componentSong);
}
setIsEditDialogOpen(true);
}
// 处理播放/暂停
const togglePlay = (songId: string) => {
const song = songs.find(s => s.id === songId)
// 如果找不到歌曲或没有音频URL则显示提示f
if (!song || !song.audioUrl) {
toast({
title: "无法播放",
description: "该歌曲没有可播放的音频文件",
variant: "destructive",
})
return
}
// 如果已经在播放这首歌,则暂停
if (playingSongId === songId) {
if (audioRef.current) {
audioRef.current.pause()
}
setPlayingSongId(null)
return
}
// 如果正在播放其他歌曲,先停止
if (playingSongId && audioRef.current) {
audioRef.current.pause()
}
// 播放新歌曲
setIsAudioLoading(true)
setPlayingSongId(songId)
// 如果是新的URL则设置src
if (currentSongUrlRef.current !== song.audioUrl && audioRef.current) {
currentSongUrlRef.current = song.audioUrl
audioRef.current.src = song.audioUrl
audioRef.current.load()
} else if (audioRef.current) {
// 如果是相同的URL直接播放
audioRef.current.play().catch(err => {
console.error("播放失败:", err)
setPlayingSongId(null)
setIsAudioLoading(false)
})
}
}
// 处理音量变化
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 toggleMute = () => {
setIsMuted(!isMuted)
}
// 处理发布歌曲
const handlePublishSong = async (songId: string) => {
try {
// 找到要发布的歌曲
const songToPublish = songs.find(s => s.id === songId);
if (!songToPublish) {
throw new Error('找不到要发布的歌曲');
}
setIsLoading(true);
console.log('正在发布歌曲', songId, songToPublish.name);
// 调用发布API
const publishedSong = await publishSong(songId);
console.log('发布成功', publishedSong);
// 更新本地歌曲数据
setSongs((prevSongs) => prevSongs.map((song) => {
if (song.id === songId) {
return {
...song,
status: "已发布",
releaseDate: new Date().toISOString().split('T')[0],
rawData: publishedSong.rawData
};
}
return song;
}));
// 触发刷新
setRefreshTrigger(prev => prev + 1);
toast({
title: "发布成功",
description: `歌曲 "${songToPublish.name}" 已成功发布`,
variant: "default",
});
} catch (error) {
console.error('发布歌曲失败:', error);
toast({
title: "发布失败",
description: error instanceof Error ? error.message : "无法发布歌曲,请稍后重试",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<DashboardHeader heading="歌曲管理" text="管理洛天依的歌曲库">
<AddSongDialog onSave={handleAddSong} />
</DashboardHeader>
<div className="flex items-center justify-between space-y-2 mb-6">
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="搜索歌曲..."
className="w-[300px] pl-8 border-none bg-white shadow-md focus-visible:ring-pink-500"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1) // 重置到第一页
}}
/>
</div>
</div>
{/* 音频控制面板 - 当有歌曲正在播放时显示 */}
{playingSongId && (
<div className="flex items-center space-x-4 bg-pink-50 py-2 px-4 rounded-full shadow-sm">
<div className="flex-shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-pink-500 text-white hover:bg-pink-600"
onClick={() => togglePlay(playingSongId)}
>
{isAudioLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-pink-700">
{songs.find(s => s.id === playingSongId)?.name}
</div>
<div className="text-xs text-pink-500">
{songs.find(s => s.id === playingSongId)?.composer}
</div>
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-pink-100"
onClick={toggleMute}
>
{isMuted ? <VolumeX className="h-3.5 w-3.5 text-pink-500" /> : <Volume2 className="h-3.5 w-3.5 text-pink-500" />}
</Button>
<div className="w-24">
<Slider
value={[volume]}
min={0}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="h-1"
/>
</div>
</div>
</div>
)}
</div>
<Card className="border-none shadow-lg bg-gradient-to-br from-white to-purple-50">
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600"></span>
<div className="ml-2 h-1 w-10 bg-gradient-to-r from-purple-600 to-pink-600 rounded-full"></div>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
...
</TableCell>
</TableRow>
) : songs.length > 0 ? (
songs.map((song) => (
<TableRow key={song.id} className="hover:bg-gray-50 transition-colors">
<TableCell>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 rounded-full ${
playingSongId === song.id
? "bg-pink-500 text-white hover:bg-pink-600"
: "bg-pink-100 hover:bg-pink-200 text-pink-600"
}`}
onClick={() => togglePlay(song.id)}
disabled={isAudioLoading && playingSongId === song.id}
>
{isAudioLoading && playingSongId === song.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : playingSongId === song.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TableCell>
<TableCell className="font-medium">{song.id}</TableCell>
<TableCell className="font-medium text-pink-600">{song.name}</TableCell>
<TableCell>{song.composer}</TableCell>
<TableCell>{song.lyricist}</TableCell>
<TableCell>{song.duration}</TableCell>
<TableCell>
<Badge
className={
song.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
}
>
{song.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href={`/songs/${song.id}`}>
<Eye className="h-4 w-4" />
<span className="sr-only"></span>
</Link>
</Button>
{song.status !== "已发布" && (
<>
<Button
variant="ghost"
size="icon"
className="hover:bg-pink-50 hover:text-pink-600"
onClick={() => openEditDialog(song)}
>
<Edit className="h-4 w-4" />
</Button>
<PublishConfirmationDialog
title="发布歌曲"
description="发布后该歌曲将可被用户使用"
itemName={song.name}
onPublish={() => handlePublishSong(song.id)}
/>
<DeleteConfirmationDialog
title="删除歌曲"
description="此操作将永久删除该歌曲及其所有相关数据。"
itemName={song.name}
onDelete={() => handleDeleteSong(song.id)}
/>
</>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground">
{songs.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, totalSongs)} {totalSongs}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
</Button>
<Button
variant="outline"
size="sm"
className="hover:bg-pink-50 hover:text-pink-700 transition-all duration-200"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0}
>
</Button>
</div>
</CardFooter>
</Card>
{/* 编辑歌曲对话框 - 当选中歌曲时显示 */}
{selectedSong && isEditDialogOpen && (
<AddSongDialog
mode="edit"
initialSong={selectedSong}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSave={handleEditSong}
/>
)}
</DashboardShell>
)
}