pmc c0fe1f502b
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 1h5m35s
feat: update card models, admin pages, and add migrations
- Update card models, serializers, views and URLs
- Update dances, songs, users admin pages and API modules
- Add card migrations (merge furniture into decoration)
- Update middleware and settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:38:48 +08:00

368 lines
14 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation"
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, Video, Eye, Plus, Loader2, Archive } from "lucide-react"
import { AddDanceDialog } from "@/components/dances/add-dance-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/hooks/use-toast"
import { getDances, deleteDance, publishDance, archiveDance } from "@/lib/api/dances"
import { isSuperUser } from "@/lib/api/auth"
import type { Dance } from "@/lib/api/types"
function formatDate(dateStr?: string): string {
if (!dateStr) return ""
try {
return new Date(dateStr).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
} catch {
return dateStr
}
}
export default function DancesPage() {
const { toast } = useToast()
const router = useRouter()
const [dances, setDances] = useState<Dance[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [danceToEdit, setDanceToEdit] = useState<Dance | undefined>(undefined)
const itemsPerPage = 10
const fetchDances = useCallback(async () => {
setIsLoading(true)
try {
const result = await getDances(currentPage, itemsPerPage, searchTerm)
if (result.data) {
setDances(result.data)
setTotalItems(result.pagination?.totalItems || 0)
}
} catch (error) {
toast({
title: "加载失败",
description: "无法获取舞蹈列表",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}, [currentPage, searchTerm, itemsPerPage, toast])
useEffect(() => {
fetchDances()
}, [fetchDances])
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 处理添加/编辑舞蹈后刷新列表
const handleDanceSaved = () => {
setDanceToEdit(undefined)
setIsAddDialogOpen(false)
fetchDances()
toast({
title: "保存成功",
description: "舞蹈数据已更新",
})
}
// 处理删除舞蹈
const handleDeleteDance = async (danceId: string, danceName: string) => {
try {
await deleteDance(danceId)
await fetchDances()
toast({
title: "删除成功",
description: `舞蹈 ${danceName} 已成功删除`,
variant: "destructive",
})
} catch (error) {
toast({
title: "删除失败",
description: "无法删除该舞蹈",
variant: "destructive",
})
}
}
// 发布舞蹈
const handlePublishDance = async (danceId: string, danceName: string) => {
try {
await publishDance(danceId)
await fetchDances()
toast({
title: "发布成功",
description: `舞蹈 "${danceName}" 已成功发布`,
})
} catch (error) {
toast({
title: "发布失败",
description: "无法发布舞蹈,请稍后重试",
variant: "destructive",
})
}
}
// 归档舞蹈
const handleArchiveDance = async (danceId: string, danceName: string) => {
try {
await archiveDance(danceId)
await fetchDances()
toast({
title: "归档成功",
description: `舞蹈 "${danceName}" 已归档`,
})
} catch (error) {
toast({
title: "归档失败",
description: "无法归档舞蹈,请稍后重试",
variant: "destructive",
})
}
}
// 打开编辑对话框
const openEditDialog = (dance: Dance) => {
setDanceToEdit(dance)
setIsAddDialogOpen(true)
}
// 查看舞蹈详情
const viewDanceDetails = (dance: Dance) => {
router.push(`/dances/${dance.id}`)
}
return (
<DashboardShell>
<DashboardHeader heading="舞蹈管理" text="管理洛天依的舞蹈库">
<Button
onClick={() => {
setDanceToEdit(undefined)
setIsAddDialogOpen(true)
}}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</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-purple-500"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}}
/>
</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>
{isLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-8 w-8 animate-spin text-purple-500" />
</div>
) : (
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[80px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dances.map((dance) => (
<TableRow key={dance.id} className="hover:bg-gray-50 transition-colors">
<TableCell>
<div className="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">
<Video className="h-4 w-4" />
</div>
</TableCell>
<TableCell className="font-medium">{dance.id}</TableCell>
<TableCell className="font-medium text-purple-600">{dance.name}</TableCell>
<TableCell>{dance.choreographer || "未知"}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
dance.difficulty === "初级"
? "bg-green-100 text-green-800"
: dance.difficulty === "中等" || dance.difficulty === "中级"
? "bg-blue-100 text-blue-800"
: dance.difficulty === "中高级"
? "bg-yellow-100 text-yellow-800"
: dance.difficulty === "高级"
? "bg-orange-100 text-orange-800"
: dance.difficulty === "专业"
? "bg-red-100 text-red-800"
: "bg-gray-100 text-gray-800"
}
>
{dance.difficulty || "未知"}
</Badge>
</TableCell>
<TableCell>{dance.category || "未分类"}</TableCell>
<TableCell>{dance.duration || "未知"}</TableCell>
<TableCell>{formatDate(dance.publishedAt) || "-"}</TableCell>
<TableCell>
<Badge
className={
dance.status === "已发布"
? "bg-green-500 hover:bg-green-600"
: dance.status === "已归档"
? "bg-orange-500 hover:bg-orange-600"
: "bg-gray-500 hover:bg-gray-600"
}
>
{dance.status || "草稿"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-1">
<Button
variant="ghost"
size="icon"
className="hover:bg-purple-50 hover:text-purple-600"
onClick={() => viewDanceDetails(dance)}
>
<Eye className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
{/* 草稿状态:显示发布按钮 */}
{dance.status === "草稿" && (
<PublishConfirmationDialog
title="发布舞蹈"
description="发布后该舞蹈将对手机端可见"
itemName={dance.name}
onPublish={() => handlePublishDance(dance.id, dance.name)}
/>
)}
{/* 已发布状态:显示归档按钮 */}
{dance.status === "已发布" && (
<Button
variant="ghost"
size="icon"
className="hover:bg-orange-50 hover:text-orange-600"
title="归档"
onClick={() => handleArchiveDance(dance.id, dance.name)}
>
<Archive className="h-4 w-4" />
</Button>
)}
{(dance.status !== "已发布" || isSuperUser()) && (
<>
<Button
variant="ghost"
size="icon"
className="hover:bg-purple-50 hover:text-purple-600"
onClick={() => openEditDialog(dance)}
>
<Edit className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<DeleteConfirmationDialog
title="删除舞蹈"
description="此操作将永久删除该舞蹈及其所有相关数据。"
itemName={dance.name}
onDelete={() => handleDeleteDance(dance.id, dance.name)}
/>
</>
)}
</div>
</TableCell>
</TableRow>
))}
{dances.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
<CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground">
{dances.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
className="hover:bg-purple-50 hover:text-purple-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-purple-50 hover:text-purple-700 transition-all duration-200"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0}
>
</Button>
</div>
</CardFooter>
</Card>
{/* 添加/编辑舞蹈对话框 */}
<AddDanceDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onDanceAdded={handleDanceSaved}
editDance={danceToEdit}
/>
</DashboardShell>
)
}