pmc bd95ba470c feat: update admin panel, API modules, and add migrations
- 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>
2026-03-20 13:06:50 +08:00

344 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 { AddOutfitDialog } from "@/components/outfits/add-outfit-dialog"
import type { DisplayOutfit } from "@/components/outfits/add-outfit-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 { getOutfits, deleteOutfit, publishOutfit, archiveOutfit } from "@/lib/api/outfits"
import type { Outfit } 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
}
}
function toDisplayOutfit(outfit: Outfit): DisplayOutfit {
return {
id: outfit.id,
name: outfit.name,
type: outfit.category || "",
rarity: outfit.rarity || "",
description: outfit.description || "",
releaseDate: formatDate(outfit.publishedAt || outfit.createdAt || ""),
status: outfit.status || "未发布",
activatedCount: outfit.activeCardsCount || 0,
image: outfit.imageUrl || "",
}
}
export default function OutfitsPage() {
const { toast } = useToast()
const [outfits, setOutfits] = useState<DisplayOutfit[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [selectedOutfit, setSelectedOutfit] = useState<DisplayOutfit | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
const itemsPerPage = 10
const fetchOutfits = useCallback(async () => {
try {
setLoading(true)
const response = await getOutfits({
page: currentPage,
pageSize: itemsPerPage,
search: searchTerm || undefined,
})
setOutfits(response.items.map(toDisplayOutfit))
setTotalItems(response.total)
} catch (error) {
console.error("获取服装列表失败:", error)
toast({
title: "获取失败",
description: "无法获取服装列表,请稍后重试",
variant: "destructive",
})
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, toast])
useEffect(() => {
fetchOutfits()
}, [fetchOutfits])
const totalPages = Math.ceil(totalItems / itemsPerPage)
const handleAddOutfit = (newOutfit: DisplayOutfit) => {
fetchOutfits()
toast({
title: "添加成功",
description: `服装 ${newOutfit.name} 已成功添加`,
})
}
const handleEditOutfit = (updatedOutfit: DisplayOutfit) => {
fetchOutfits()
setSelectedOutfit(null)
setIsEditDialogOpen(false)
toast({
title: "更新成功",
description: `服装 ${updatedOutfit.name} 已成功更新`,
})
}
const handleDeleteOutfit = async (outfitId: string) => {
try {
await deleteOutfit(outfitId)
await fetchOutfits()
toast({
title: "删除成功",
description: "服装已成功删除",
variant: "destructive",
})
} catch (error) {
console.error("删除服装失败:", error)
toast({
title: "删除失败",
description: "无法删除服装,请稍后重试",
variant: "destructive",
})
}
}
const handlePublishOutfit = async (outfitId: string, outfitName: string) => {
try {
await publishOutfit(outfitId)
await fetchOutfits()
toast({
title: "发布成功",
description: `服装 "${outfitName}" 已成功发布`,
})
} catch (error) {
console.error("发布服装失败:", error)
toast({
title: "发布失败",
description: "无法发布服装,请稍后重试",
variant: "destructive",
})
}
}
const handleArchiveOutfit = async (outfitId: string, outfitName: string) => {
try {
await archiveOutfit(outfitId)
await fetchOutfits()
toast({
title: "归档成功",
description: `服装 "${outfitName}" 已归档`,
})
} catch (error) {
console.error("归档服装失败:", error)
toast({
title: "归档失败",
description: "无法归档服装,请稍后重试",
variant: "destructive",
})
}
}
const openEditDialog = (outfit: DisplayOutfit) => {
setSelectedOutfit(outfit)
setIsEditDialogOpen(true)
}
return (
<DashboardShell>
<DashboardHeader heading="服装管理" text="管理洛天依的服装卡牌">
<AddOutfitDialog onSave={handleAddOutfit} />
</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>
{outfits.map((outfit) => (
<TableRow key={outfit.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="font-medium">{outfit.id}</TableCell>
<TableCell className="font-medium text-pink-600">{outfit.name}</TableCell>
<TableCell>{outfit.type}</TableCell>
<TableCell>{outfit.rarity}</TableCell>
<TableCell>{outfit.releaseDate || "-"}</TableCell>
<TableCell>
<Badge
className={
outfit.status === "已发布"
? "bg-green-500 hover:bg-green-600"
: outfit.status === "已归档"
? "bg-orange-500 hover:bg-orange-600"
: "bg-gray-500 hover:bg-gray-600"
}
>
{outfit.status}
</Badge>
</TableCell>
<TableCell className="font-medium">{outfit.activatedCount}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="hover:bg-pink-50 hover:text-pink-600" asChild>
<Link href={`/outfits/${outfit.id}`}>
<Eye className="h-4 w-4" />
<span className="sr-only"></span>
</Link>
</Button>
{outfit.status === "草稿" && (
<PublishConfirmationDialog
title="发布服装"
description="发布后该服装将可被用于卡牌生成"
itemName={outfit.name}
onPublish={() => handlePublishOutfit(outfit.id, outfit.name)}
/>
)}
{outfit.status === "已发布" && (
<Button
variant="ghost"
size="icon"
className="hover:bg-orange-50 hover:text-orange-600"
title="归档"
onClick={() => handleArchiveOutfit(outfit.id, outfit.name)}
>
<Archive className="h-4 w-4" />
</Button>
)}
{(outfit.status !== "已发布" || isSuperUser()) && (
<>
<Button
variant="ghost"
size="icon"
className="hover:bg-pink-50 hover:text-pink-600"
onClick={() => openEditDialog(outfit)}
>
<Edit className="h-4 w-4" />
</Button>
<DeleteConfirmationDialog
title="删除服装"
description="此操作将永久删除该服装及其所有相关数据。"
itemName={outfit.name}
onDelete={() => handleDeleteOutfit(outfit.id)}
/>
</>
)}
</TableCell>
</TableRow>
))}
{outfits.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">
{outfits.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>
{selectedOutfit && isEditDialogOpen && (
<AddOutfitDialog
mode="edit"
initialOutfit={selectedOutfit}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSave={handleEditOutfit}
/>
)}
</DashboardShell>
)
}