- 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>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback } 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, Eye, Loader2, Archive } from "lucide-react"
|
|
import { AddPropDialog } from "@/components/props/add-prop-dialog"
|
|
import type { Prop as ComponentProp } from "@/components/props/prop-detail-dialog"
|
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
|
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
|
|
import { useToast } from "@/components/ui/use-toast"
|
|
import { isSuperUser } from "@/lib/api/auth"
|
|
import { getProps, deleteProp, publishProp, archiveProp } from "@/lib/api/props"
|
|
import type { Prop } from "@/lib/api/types"
|
|
import Link from "next/link"
|
|
|
|
// 格式化日期时间
|
|
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
|
|
}
|
|
}
|
|
|
|
// 将 API Prop 转换为组件显示用的 Prop
|
|
function toDisplayProp(prop: Prop): ComponentProp {
|
|
return {
|
|
id: prop.id,
|
|
name: prop.name,
|
|
type: prop.category || "",
|
|
rarity: prop.rarity || "",
|
|
description: prop.description || "",
|
|
releaseDate: formatDate(prop.publishedAt || prop.createdAt || ""),
|
|
status: prop.status || "未发布",
|
|
activatedCount: prop.activeCardsCount || 0,
|
|
image: prop.imageUrl || "/placeholder.svg?height=300&width=300",
|
|
}
|
|
}
|
|
|
|
export default function PropsPage() {
|
|
const { toast } = useToast()
|
|
const [props, setProps] = useState<ComponentProp[]>([])
|
|
const [searchTerm, setSearchTerm] = useState("")
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const [totalItems, setTotalItems] = useState(0)
|
|
const [selectedProp, setSelectedProp] = useState<ComponentProp | null>(null)
|
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const itemsPerPage = 10
|
|
|
|
// 从后端获取道具列表
|
|
const fetchProps = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await getProps({
|
|
page: currentPage,
|
|
pageSize: itemsPerPage,
|
|
search: searchTerm || undefined,
|
|
})
|
|
setProps(response.items.map(toDisplayProp))
|
|
setTotalItems(response.total)
|
|
} catch (error) {
|
|
console.error("获取道具列表失败:", error)
|
|
toast({
|
|
title: "获取失败",
|
|
description: "无法获取道具列表,请稍后重试",
|
|
variant: "destructive",
|
|
})
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [currentPage, searchTerm, toast])
|
|
|
|
useEffect(() => {
|
|
fetchProps()
|
|
}, [fetchProps])
|
|
|
|
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
|
|
|
// 处理添加道具
|
|
const handleAddProp = (newProp: ComponentProp) => {
|
|
// 添加后刷新列表
|
|
fetchProps()
|
|
toast({
|
|
title: "添加成功",
|
|
description: `道具 ${newProp.name} 已成功添加`,
|
|
})
|
|
}
|
|
|
|
// 处理编辑道具
|
|
const handleEditProp = (updatedProp: ComponentProp) => {
|
|
// 编辑后刷新列表
|
|
fetchProps()
|
|
setSelectedProp(null)
|
|
setIsEditDialogOpen(false)
|
|
toast({
|
|
title: "更新成功",
|
|
description: `道具 ${updatedProp.name} 已成功更新`,
|
|
})
|
|
}
|
|
|
|
// 处理删除道具 - 调用真实的后端 API
|
|
const handleDeleteProp = async (propId: string) => {
|
|
try {
|
|
await deleteProp(propId)
|
|
// 删除后刷新列表
|
|
await fetchProps()
|
|
toast({
|
|
title: "删除成功",
|
|
description: "道具已成功删除",
|
|
variant: "destructive",
|
|
})
|
|
} catch (error) {
|
|
console.error("删除道具失败:", error)
|
|
toast({
|
|
title: "删除失败",
|
|
description: "无法删除道具,请稍后重试",
|
|
variant: "destructive",
|
|
})
|
|
}
|
|
}
|
|
|
|
// 发布道具
|
|
const handlePublishProp = async (propId: string, propName: string) => {
|
|
try {
|
|
await publishProp(propId)
|
|
await fetchProps()
|
|
toast({
|
|
title: "发布成功",
|
|
description: `道具 "${propName}" 已成功发布`,
|
|
})
|
|
} catch (error) {
|
|
console.error("发布道具失败:", error)
|
|
toast({
|
|
title: "发布失败",
|
|
description: "无法发布道具,请稍后重试",
|
|
variant: "destructive",
|
|
})
|
|
}
|
|
}
|
|
|
|
// 归档道具
|
|
const handleArchiveProp = async (propId: string, propName: string) => {
|
|
try {
|
|
await archiveProp(propId)
|
|
await fetchProps()
|
|
toast({
|
|
title: "归档成功",
|
|
description: `道具 "${propName}" 已归档`,
|
|
})
|
|
} catch (error) {
|
|
console.error("归档道具失败:", error)
|
|
toast({
|
|
title: "归档失败",
|
|
description: "无法归档道具,请稍后重试",
|
|
variant: "destructive",
|
|
})
|
|
}
|
|
}
|
|
|
|
// 打开编辑对话框
|
|
const openEditDialog = (prop: ComponentProp) => {
|
|
setSelectedProp(prop)
|
|
setIsEditDialogOpen(true)
|
|
}
|
|
|
|
return (
|
|
<DashboardShell>
|
|
<DashboardHeader heading="道具管理" text="管理洛天依的道具卡牌">
|
|
<AddPropDialog onSave={handleAddProp} />
|
|
</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>
|
|
</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>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-48">
|
|
<Loader2 className="h-8 w-8 animate-spin text-pink-500" />
|
|
<span className="ml-2 text-muted-foreground">加载中...</span>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="bg-gray-50">
|
|
<TableRow>
|
|
<TableHead className="w-[100px]">ID</TableHead>
|
|
<TableHead>道具名称</TableHead>
|
|
<TableHead>类型</TableHead>
|
|
<TableHead>稀有度</TableHead>
|
|
<TableHead>发布日期</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>激活数量</TableHead>
|
|
<TableHead className="text-right">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{props.map((prop) => (
|
|
<TableRow key={prop.id} className="hover:bg-gray-50 transition-colors">
|
|
<TableCell className="font-medium">{prop.id}</TableCell>
|
|
<TableCell className="font-medium text-pink-600">{prop.name}</TableCell>
|
|
<TableCell>{prop.type}</TableCell>
|
|
<TableCell>{prop.rarity}</TableCell>
|
|
<TableCell>{prop.releaseDate || "-"}</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
className={
|
|
prop.status === "已发布"
|
|
? "bg-green-500 hover:bg-green-600"
|
|
: prop.status === "已归档"
|
|
? "bg-orange-500 hover:bg-orange-600"
|
|
: "bg-gray-500 hover:bg-gray-600"
|
|
}
|
|
>
|
|
{prop.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-medium">{prop.activatedCount}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
|
|
<Link href={`/props/${prop.id}`}>
|
|
<Eye className="h-4 w-4" />
|
|
<span className="sr-only">查看详情</span>
|
|
</Link>
|
|
</Button>
|
|
|
|
{/* 草稿状态:显示发布按钮 */}
|
|
{prop.status === "草稿" && (
|
|
<PublishConfirmationDialog
|
|
title="发布道具"
|
|
description="发布后该道具将可被用于卡牌生成"
|
|
itemName={prop.name}
|
|
onPublish={() => handlePublishProp(prop.id, prop.name)}
|
|
/>
|
|
)}
|
|
|
|
{/* 已发布状态:显示归档按钮 */}
|
|
{prop.status === "已发布" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="hover:bg-orange-50 hover:text-orange-600"
|
|
title="归档"
|
|
onClick={() => handleArchiveProp(prop.id, prop.name)}
|
|
>
|
|
<Archive className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{(prop.status !== "已发布" || isSuperUser()) && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="hover:bg-pink-50 hover:text-pink-600"
|
|
onClick={() => openEditDialog(prop)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<DeleteConfirmationDialog
|
|
title="删除道具"
|
|
description="此操作将永久删除该道具及其所有相关数据。"
|
|
itemName={prop.name}
|
|
onDelete={() => handleDeleteProp(prop.id)}
|
|
/>
|
|
</>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
|
|
{props.length === 0 && (
|
|
<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">
|
|
显示 {props.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-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>
|
|
|
|
{/* 编辑道具对话框 - 当选中道具时显示 */}
|
|
{selectedProp && isEditDialogOpen && (
|
|
<AddPropDialog
|
|
mode="edit"
|
|
initialProp={selectedProp}
|
|
open={isEditDialogOpen}
|
|
onOpenChange={setIsEditDialogOpen}
|
|
onSave={handleEditProp}
|
|
/>
|
|
)}
|
|
</DashboardShell>
|
|
)
|
|
}
|