feat: update card models, admin pages, and add migrations
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 1h5m35s
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 1h5m35s
- 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>
This commit is contained in:
parent
55ca2cbdaf
commit
c0fe1f502b
@ -153,7 +153,25 @@
|
||||
"Bash(python -c \"from card.serializers import MobileClothingTemplateSerializer; print\\(''Serializer OK''\\)\")",
|
||||
"Bash(python -c \"from card.views import MobileClothingListView; print\\(''View OK''\\)\")",
|
||||
"Bash(python -c \"from card.urls import urlpatterns; print\\(f''URLs OK, {len\\(urlpatterns\\)} routes''\\)\")",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=qy_lty.settings python -c \"import django; django.setup\\(\\); from card.serializers import MobileClothingTemplateSerializer; print\\(''Serializer OK''\\); from card.views import MobileClothingListView; print\\(''View OK''\\); from card.urls import urlpatterns; print\\(f''URLs OK, {len\\(urlpatterns\\)} routes''\\)\")"
|
||||
"Bash(DJANGO_SETTINGS_MODULE=qy_lty.settings python -c \"import django; django.setup\\(\\); from card.serializers import MobileClothingTemplateSerializer; print\\(''Serializer OK''\\); from card.views import MobileClothingListView; print\\(''View OK''\\); from card.urls import urlpatterns; print\\(f''URLs OK, {len\\(urlpatterns\\)} routes''\\)\")",
|
||||
"Read(//c/Unity2022project/LTY_App_Project_URP/Assets/Scripts/Manager/**)",
|
||||
"Read(//c/Unity2022project/LTY_App_Project_URP/Assets/Scripts/**)",
|
||||
"Bash(netstat -ano)",
|
||||
"Bash(powershell -Command \"Get-CimInstance Win32_Process -Filter ''ProcessId=266196 or ProcessId=158040'' | Select-Object ProcessId,CommandLine | Format-List\")",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:8000/api/card/mobile/clothing/)",
|
||||
"Bash(powershell -Command \"Get-CimInstance Win32_Process -Filter \"\"Name=''python.exe''\"\" | Select-Object ProcessId, CommandLine | Format-List\")",
|
||||
"Bash(C:/python/python.exe manage.py runserver 0.0.0.0:8000)",
|
||||
"Bash(curl -s http://localhost:8000/api/card/mobile/clothing/)",
|
||||
"Read(//c/Users/admin/AppData/LocalLow/qy/LiLa/products/**)",
|
||||
"Bash(C:/python/python.exe -c \"import json; data=json.load\\(open\\(r''c:\\\\Users\\\\admin\\\\AppData\\\\LocalLow\\\\qy\\\\LiLa\\\\products\\\\products.json'',''r'',encoding=''utf-8''\\)\\); json.dump\\(data, open\\(r''c:\\\\Users\\\\admin\\\\AppData\\\\LocalLow\\\\qy\\\\LiLa\\\\products\\\\products.json'',''w'',encoding=''utf-8''\\), ensure_ascii=False, indent=2\\)\")",
|
||||
"Bash(curl -s http://localhost:8000/api/card/mobile/products.json)",
|
||||
"Bash(C:/python/python.exe -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''Categories:'''', list\\(d.keys\\(\\)\\)\\); [print\\(f''''{k}: {v[\"\"count\"\"]} items''''\\) for k,v in d.items\\(\\)]\")",
|
||||
"Bash(C:/python/python.exe manage.py shell -c \":*)",
|
||||
"Bash(C:/python/python.exe -c \":*)",
|
||||
"Bash(curl -s http://localhost:8000/api/card/category/dance/)",
|
||||
"Bash(C:/python/python.exe -X utf8 -c \"import sys,json; sys.stdout.reconfigure\\(encoding=''''utf-8''''\\); d=json.load\\(sys.stdin\\); [print\\(f''''ID={i[\"\"id\"\"]} status={i.get\\(\"\"status\"\",\"\"?\"\"\\)} name={i[\"\"name\"\"]}''''\\) for i in d.get\\(''''data'''',d\\).get\\(''''results'''',d.get\\(''''data'''',[]\\)\\)]\")",
|
||||
"Bash(ls c:/Users/admin/Desktop/Lila-Server/qy_lty/settings*)",
|
||||
"Bash(python manage.py makemigrations card)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"C:\\Users\\admin\\.claude"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DashboardShell } from "@/components/dashboard-shell"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
@ -9,149 +9,130 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
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, Trash2 } from "lucide-react"
|
||||
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"
|
||||
|
||||
// 初始舞蹈数据
|
||||
const initialDances: Dance[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "千本樱",
|
||||
choreographer: "洛天依工作室",
|
||||
duration: "3:45",
|
||||
difficulty: "中等",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "基于《千本樱》歌曲的经典舞蹈编排,动作流畅优美,适合中等水平的舞者。",
|
||||
motionFile: "senbonzakura_motion.fbx",
|
||||
category: "日式",
|
||||
tags: ["经典", "流行", "日式"],
|
||||
createdAt: "2023-01-15T08:30:00Z",
|
||||
updatedAt: "2023-02-20T14:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "权御天下",
|
||||
choreographer: "洛天依动作组",
|
||||
duration: "4:20",
|
||||
difficulty: "高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "中国风舞蹈,动作幅度大,需要较高的舞蹈基础,展现古风韵味。",
|
||||
motionFile: "quanyutianxia_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["高难度", "中国风", "古风"],
|
||||
createdAt: "2023-03-10T10:45:00Z",
|
||||
updatedAt: "2023-04-05T16:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "达拉崩吧",
|
||||
choreographer: "洛天依舞蹈工作室",
|
||||
duration: "3:10",
|
||||
difficulty: "初级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "轻松欢快的舞蹈,适合初学者,动作简单易学。",
|
||||
motionFile: "dalabengba_motion.fbx",
|
||||
category: "流行",
|
||||
tags: ["简单", "欢快", "流行"],
|
||||
createdAt: "2023-05-20T09:15:00Z",
|
||||
updatedAt: "2023-05-25T11:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "普通DISCO",
|
||||
choreographer: "洛天依动作设计组",
|
||||
duration: "3:30",
|
||||
difficulty: "中等",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "现代disco风格舞蹈,节奏感强,动作活力四射。",
|
||||
motionFile: "disco_motion.fbx",
|
||||
category: "现代",
|
||||
tags: ["活力", "现代", "disco"],
|
||||
createdAt: "2023-06-05T14:30:00Z",
|
||||
updatedAt: "2023-06-10T17:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "华灯宴",
|
||||
choreographer: "古风舞蹈团队",
|
||||
duration: "4:50",
|
||||
difficulty: "高级",
|
||||
videoUrl: "/placeholder.svg?height=300&width=400",
|
||||
coverUrl: "/placeholder.svg?height=300&width=400",
|
||||
description: "古风舞蹈,动作优美细腻,需要较高的舞蹈技巧和表现力。",
|
||||
motionFile: "hualangyan_motion.fbx",
|
||||
category: "中国风",
|
||||
tags: ["古风", "优美", "高难度"],
|
||||
createdAt: "2023-07-12T11:20:00Z",
|
||||
updatedAt: "2023-07-18T13:40:00Z",
|
||||
},
|
||||
]
|
||||
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[]>(initialDances)
|
||||
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 [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [danceToDelete, setDanceToDelete] = useState<Dance | null>(null)
|
||||
|
||||
const itemsPerPage = 5
|
||||
const itemsPerPage = 10
|
||||
|
||||
// 过滤和分页
|
||||
const filteredDances = dances.filter(
|
||||
(dance) =>
|
||||
dance.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dance.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(dance.choreographer && dance.choreographer.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(dance.category && dance.category.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(dance.tags && dance.tags.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase()))),
|
||||
)
|
||||
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])
|
||||
|
||||
const totalPages = Math.ceil(filteredDances.length / itemsPerPage)
|
||||
const paginatedDances = filteredDances.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
useEffect(() => {
|
||||
fetchDances()
|
||||
}, [fetchDances])
|
||||
|
||||
// 处理添加舞蹈
|
||||
const handleAddDance = (newDance: Dance) => {
|
||||
setDances((prevDances) => [...prevDances, newDance])
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `舞蹈 ${newDance.name} 已成功添加`,
|
||||
})
|
||||
}
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
||||
|
||||
// 处理编辑舞蹈
|
||||
const handleEditDance = (updatedDance: Dance) => {
|
||||
setDances((prevDances) => prevDances.map((dance) => (dance.id === updatedDance.id ? updatedDance : dance)))
|
||||
// 处理添加/编辑舞蹈后刷新列表
|
||||
const handleDanceSaved = () => {
|
||||
setDanceToEdit(undefined)
|
||||
setIsAddDialogOpen(false)
|
||||
fetchDances()
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `舞蹈 ${updatedDance.name} 已成功更新`,
|
||||
title: "保存成功",
|
||||
description: "舞蹈数据已更新",
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除舞蹈
|
||||
const handleDeleteDance = () => {
|
||||
if (!danceToDelete) return
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setDances((prevDances) => prevDances.filter((dance) => dance.id !== danceToDelete.id))
|
||||
setDanceToDelete(null)
|
||||
setIsDeleteDialogOpen(false)
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: `舞蹈 ${danceToDelete.name} 已成功删除`,
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
@ -165,12 +146,6 @@ export default function DancesPage() {
|
||||
router.push(`/dances/${dance.id}`)
|
||||
}
|
||||
|
||||
// 打开删除确认对话框
|
||||
const openDeleteDialog = (dance: Dance) => {
|
||||
setDanceToDelete(dance)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader heading="舞蹈管理" text="管理洛天依的舞蹈库">
|
||||
@ -197,7 +172,7 @@ export default function DancesPage() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1) // 重置到第一页
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -213,100 +188,148 @@ export default function DancesPage() {
|
||||
<CardDescription>管理洛天依可以表演的舞蹈</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedDances.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 === "中等"
|
||||
? "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 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>
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => openDeleteDialog(dance)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{paginatedDances.length === 0 && (
|
||||
{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>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
没有找到匹配的舞蹈
|
||||
</TableCell>
|
||||
<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>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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">
|
||||
显示 {paginatedDances.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, filteredDances.length)} 共 {filteredDances.length} 个舞蹈
|
||||
显示 {dances.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
|
||||
{Math.min(currentPage * itemsPerPage, totalItems)} 共 {totalItems} 个舞蹈
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
@ -335,18 +358,10 @@ export default function DancesPage() {
|
||||
<AddDanceDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
onDanceAdded={danceToEdit ? handleEditDance : handleAddDance}
|
||||
onDanceAdded={handleDanceSaved}
|
||||
editDance={danceToEdit}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmationDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteDance}
|
||||
title="删除舞蹈"
|
||||
description={`确定要删除舞蹈 "${danceToDelete?.name}" 吗?此操作无法撤销。`}
|
||||
/>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,13 +8,14 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
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 { Search, Edit, Play, Pause, Eye, Volume2, VolumeX, Archive } 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 { isSuperUser } from "@/lib/api/auth"
|
||||
import { getSongs, publishSong, deleteSong, archiveSong } 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"
|
||||
@ -42,7 +43,7 @@ export default function SongsPage() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const currentSongUrlRef = useRef<string | null>(null)
|
||||
|
||||
const itemsPerPage = 5
|
||||
const itemsPerPage = 10
|
||||
|
||||
// 加载歌曲数据
|
||||
useEffect(() => {
|
||||
@ -197,22 +198,47 @@ export default function SongsPage() {
|
||||
|
||||
// 处理删除歌曲
|
||||
const handleDeleteSong = async (songId: string) => {
|
||||
// 如果正在播放该歌曲,先停止播放
|
||||
if (playingSongId === songId && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingSongId(null)
|
||||
try {
|
||||
// 如果正在播放该歌曲,先停止播放
|
||||
if (playingSongId === songId && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingSongId(null)
|
||||
}
|
||||
|
||||
await deleteSong(songId)
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "歌曲已成功删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("删除歌曲失败:", error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "无法删除该歌曲,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟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 handleArchiveSong = async (songId: string, songName: string) => {
|
||||
try {
|
||||
await archiveSong(songId)
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
toast({
|
||||
title: "归档成功",
|
||||
description: `歌曲 "${songName}" 已归档`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("归档歌曲失败:", error)
|
||||
toast({
|
||||
title: "归档失败",
|
||||
description: "无法归档歌曲,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
@ -502,7 +528,11 @@ export default function SongsPage() {
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
song.status === "已发布" ? "bg-green-500 hover:bg-green-600" : "bg-gray-500 hover:bg-gray-600"
|
||||
song.status === "已发布"
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: song.status === "已归档"
|
||||
? "bg-orange-500 hover:bg-orange-600"
|
||||
: "bg-gray-500 hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{song.status}
|
||||
@ -516,7 +546,30 @@ export default function SongsPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{song.status !== "已发布" && (
|
||||
{/* 草稿状态:显示发布按钮 */}
|
||||
{song.status !== "已发布" && song.status !== "已归档" && (
|
||||
<PublishConfirmationDialog
|
||||
title="发布歌曲"
|
||||
description="发布后该歌曲将可被用户使用"
|
||||
itemName={song.name}
|
||||
onPublish={() => handlePublishSong(song.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 已发布状态:显示归档按钮 */}
|
||||
{song.status === "已发布" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-orange-50 hover:text-orange-600"
|
||||
title="归档"
|
||||
onClick={() => handleArchiveSong(song.id, song.name)}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(song.status !== "已发布" || isSuperUser()) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -527,13 +580,6 @@ export default function SongsPage() {
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<PublishConfirmationDialog
|
||||
title="发布歌曲"
|
||||
description="发布后该歌曲将可被用户使用"
|
||||
itemName={song.name}
|
||||
onPublish={() => handlePublishSong(song.id)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
title="删除歌曲"
|
||||
description="此操作将永久删除该歌曲及其所有相关数据。"
|
||||
|
||||
@ -27,7 +27,7 @@ export default function UsersPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
|
||||
const itemsPerPage = 5
|
||||
const itemsPerPage = 10
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
|
||||
@ -17,6 +17,10 @@ function mapBackendDance(item: any): Dance {
|
||||
motionFile: item.model_url || "",
|
||||
category: attrs.style || "",
|
||||
tags: [],
|
||||
status: item.status_display || item.status || "",
|
||||
statusDisplay: item.status_display || item.status || "",
|
||||
publishedAt: item.published_at || "",
|
||||
activeCardsCount: item.active_cards_count || 0,
|
||||
createdAt: item.created_at || "",
|
||||
updatedAt: item.updated_at || "",
|
||||
}
|
||||
@ -119,3 +123,17 @@ export async function deleteDance(id: string) {
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 发布舞蹈
|
||||
export async function publishDance(id: string) {
|
||||
const response = await apiClient.post(`/card/templates/${id}/publish/`)
|
||||
const data = response.data?.template || response.data
|
||||
return { data: mapBackendDance(data) }
|
||||
}
|
||||
|
||||
// 归档舞蹈
|
||||
export async function archiveDance(id: string) {
|
||||
const response = await apiClient.post(`/card/templates/${id}/archive/`)
|
||||
const data = response.data?.template || response.data
|
||||
return { data: mapBackendDance(data) }
|
||||
}
|
||||
|
||||
@ -227,6 +227,24 @@ export const publishSong = async (id: string): Promise<Song> => {
|
||||
}
|
||||
};
|
||||
|
||||
// 归档歌曲
|
||||
export const archiveSong = async (id: string): Promise<Song> => {
|
||||
try {
|
||||
const response = await apiClient.post(`/card/templates/${id}/archive/`);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || '归档歌曲失败');
|
||||
}
|
||||
|
||||
return transformCardSongDetailToSong(data.data.template);
|
||||
} catch (error) {
|
||||
console.error('归档歌曲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取歌曲批次数据
|
||||
export const getSongBatches = async (songId: string): Promise<SongBatch[]> => {
|
||||
try {
|
||||
|
||||
@ -162,6 +162,10 @@ export interface Dance {
|
||||
motionFile?: string // 动作文件
|
||||
category?: string
|
||||
tags?: string[]
|
||||
status?: string
|
||||
statusDisplay?: string
|
||||
publishedAt?: string
|
||||
activeCardsCount?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
"""
|
||||
Data migration: merge furniture category into decoration.
|
||||
|
||||
For each CardTemplate with category='furniture':
|
||||
1. Create a DecorationAttributes record from FurnitureAttributes data
|
||||
2. Change the template's category to 'decoration'
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def merge_furniture_into_decoration(apps, schema_editor):
|
||||
CardTemplate = apps.get_model('card', 'CardTemplate')
|
||||
FurnitureAttributes = apps.get_model('card', 'FurnitureAttributes')
|
||||
DecorationAttributes = apps.get_model('card', 'DecorationAttributes')
|
||||
|
||||
furniture_templates = CardTemplate.objects.filter(category='furniture')
|
||||
|
||||
for template in furniture_templates:
|
||||
# Get existing furniture attributes if any
|
||||
try:
|
||||
fa = FurnitureAttributes.objects.get(template=template)
|
||||
# Create decoration attributes from furniture data
|
||||
DecorationAttributes.objects.create(
|
||||
template=template,
|
||||
decoration_type=fa.furniture_type,
|
||||
style=fa.style,
|
||||
material=fa.material,
|
||||
size=fa.dimensions, # dimensions -> size
|
||||
placement='',
|
||||
indoor_outdoor='室内',
|
||||
installation_required=fa.assembly_required,
|
||||
care_instructions=fa.care_instructions,
|
||||
)
|
||||
# Delete old furniture attributes
|
||||
fa.delete()
|
||||
except FurnitureAttributes.DoesNotExist:
|
||||
# No furniture attrs, just create empty decoration attrs
|
||||
DecorationAttributes.objects.create(template=template)
|
||||
|
||||
# Change category
|
||||
template.category = 'decoration'
|
||||
template.save(update_fields=['category'])
|
||||
|
||||
# Also update any Card records that reference furniture category
|
||||
Card = apps.get_model('card', 'Card')
|
||||
Card.objects.filter(category='furniture').update(category='decoration')
|
||||
|
||||
# Also update any CardBatch records
|
||||
CardBatch = apps.get_model('card', 'CardBatch')
|
||||
CardBatch.objects.filter(category='furniture').update(category='decoration')
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
# Cannot reliably reverse this migration
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('card', '0012_decoration_furniture_optional_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(merge_furniture_into_decoration, reverse_migration),
|
||||
]
|
||||
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-25 09:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('card', '0013_merge_furniture_into_decoration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='card',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('clothing', '服装'), ('prop', '道具'), ('song', '音乐'), ('dance', '舞蹈'), ('decoration', '家居装饰')], max_length=20, verbose_name='类别'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cardbatch',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('clothing', '服装'), ('prop', '道具'), ('song', '音乐'), ('dance', '舞蹈'), ('decoration', '家居装饰')], max_length=20, verbose_name='类别'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cardtemplate',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('clothing', '服装'), ('prop', '道具'), ('song', '音乐'), ('dance', '舞蹈'), ('decoration', '家居装饰')], max_length=20, verbose_name='类别'),
|
||||
),
|
||||
]
|
||||
@ -18,8 +18,7 @@ class CardTemplate(models.Model):
|
||||
('prop', '道具'),
|
||||
('song', '音乐'),
|
||||
('dance', '舞蹈'),
|
||||
('furniture', '家具'),
|
||||
('decoration', '装饰'),
|
||||
('decoration', '家居装饰'),
|
||||
]
|
||||
|
||||
RARITY_CHOICES = [
|
||||
|
||||
@ -2,7 +2,7 @@ from rest_framework import serializers
|
||||
from .models import (
|
||||
CardTemplate, Card, CardBatch, CardUsageLog,
|
||||
ClothingAttributes, PropAttributes, SongAttributes,
|
||||
DanceAttributes, FurnitureAttributes, DecorationAttributes
|
||||
DanceAttributes, DecorationAttributes
|
||||
)
|
||||
|
||||
|
||||
@ -42,15 +42,6 @@ class DanceAttributesSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class FurnitureAttributesSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FurnitureAttributes
|
||||
fields = [
|
||||
'furniture_type', 'style', 'material', 'dimensions',
|
||||
'weight', 'assembly_required', 'max_weight_capacity', 'care_instructions'
|
||||
]
|
||||
|
||||
|
||||
class DecorationAttributesSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DecorationAttributes
|
||||
@ -72,7 +63,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
prop_attributes = PropAttributesSerializer(write_only=True, required=False)
|
||||
song_attributes = SongAttributesSerializer(write_only=True, required=False)
|
||||
dance_attributes = DanceAttributesSerializer(write_only=True, required=False)
|
||||
furniture_attributes = FurnitureAttributesSerializer(write_only=True, required=False)
|
||||
decoration_attributes = DecorationAttributesSerializer(write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
@ -84,7 +74,7 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
'status', 'status_display', 'published_at', 'price',
|
||||
'created_at', 'updated_at',
|
||||
# 新增
|
||||
'clothing_attributes', 'prop_attributes', 'song_attributes', 'dance_attributes', 'furniture_attributes', 'decoration_attributes',
|
||||
'clothing_attributes', 'prop_attributes', 'song_attributes', 'dance_attributes', 'decoration_attributes',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'published_at']
|
||||
|
||||
@ -95,7 +85,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
validated_data.pop('prop_attributes', None)
|
||||
validated_data.pop('song_attributes', None)
|
||||
validated_data.pop('dance_attributes', None)
|
||||
validated_data.pop('furniture_attributes', None)
|
||||
validated_data.pop('decoration_attributes', None)
|
||||
return super().create(validated_data)
|
||||
|
||||
@ -123,8 +112,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data
|
||||
elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'):
|
||||
representation['attributes'] = DanceAttributesSerializer(instance.dance_attrs).data
|
||||
elif instance.category == 'furniture' and hasattr(instance, 'furniture_attrs'):
|
||||
representation['attributes'] = FurnitureAttributesSerializer(instance.furniture_attrs).data
|
||||
elif instance.category == 'decoration' and hasattr(instance, 'decoration_attrs'):
|
||||
representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data
|
||||
except Exception:
|
||||
@ -137,7 +124,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
prop_data = validated_data.pop('prop_attributes', None)
|
||||
song_data = validated_data.pop('song_attributes', None)
|
||||
dance_data = validated_data.pop('dance_attributes', None)
|
||||
furniture_data = validated_data.pop('furniture_attributes', None)
|
||||
decoration_data = validated_data.pop('decoration_attributes', None)
|
||||
|
||||
# 更新主表
|
||||
@ -160,10 +146,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
|
||||
for k, v in dance_data.items():
|
||||
setattr(instance.dance_attrs, k, v)
|
||||
instance.dance_attrs.save()
|
||||
if furniture_data is not None and hasattr(instance, 'furniture_attrs'):
|
||||
for k, v in furniture_data.items():
|
||||
setattr(instance.furniture_attrs, k, v)
|
||||
instance.furniture_attrs.save()
|
||||
if decoration_data is not None and hasattr(instance, 'decoration_attrs'):
|
||||
for k, v in decoration_data.items():
|
||||
setattr(instance.decoration_attrs, k, v)
|
||||
@ -200,8 +182,6 @@ class CardTemplateDetailSerializer(CardTemplateSerializer):
|
||||
representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data
|
||||
elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'):
|
||||
representation['attributes'] = DanceAttributesSerializer(instance.dance_attrs).data
|
||||
elif instance.category == 'furniture' and hasattr(instance, 'furniture_attrs'):
|
||||
representation['attributes'] = FurnitureAttributesSerializer(instance.furniture_attrs).data
|
||||
elif instance.category == 'decoration' and hasattr(instance, 'decoration_attrs'):
|
||||
representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data
|
||||
except Exception as e:
|
||||
@ -391,8 +371,6 @@ class CategoryTemplateSerializer(CardTemplateDetailSerializer):
|
||||
representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data
|
||||
elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'):
|
||||
representation['attributes'] = DanceAttributesSerializer(instance.dance_attrs).data
|
||||
elif instance.category == 'furniture' and hasattr(instance, 'furniture_attrs'):
|
||||
representation['attributes'] = FurnitureAttributesSerializer(instance.furniture_attrs).data
|
||||
elif instance.category == 'decoration' and hasattr(instance, 'decoration_attrs'):
|
||||
representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data
|
||||
except Exception as e:
|
||||
@ -402,8 +380,9 @@ class CategoryTemplateSerializer(CardTemplateDetailSerializer):
|
||||
return representation
|
||||
|
||||
|
||||
class MobileClothingTemplateSerializer(serializers.ModelSerializer):
|
||||
"""手机端服装模板序列化器 - 只返回已发布服装的必要字段"""
|
||||
class MobileProductSerializer(serializers.ModelSerializer):
|
||||
"""手机端通用产品序列化器 - 适用于所有分类"""
|
||||
category_display = serializers.SerializerMethodField()
|
||||
card_type_display = serializers.SerializerMethodField()
|
||||
rarity_display = serializers.SerializerMethodField()
|
||||
status_display = serializers.SerializerMethodField()
|
||||
@ -415,6 +394,7 @@ class MobileClothingTemplateSerializer(serializers.ModelSerializer):
|
||||
model = CardTemplate
|
||||
fields = [
|
||||
'id', 'name', 'description',
|
||||
'category', 'category_display',
|
||||
'card_type', 'card_type_display',
|
||||
'rarity', 'rarity_display',
|
||||
'image_url',
|
||||
@ -424,6 +404,9 @@ class MobileClothingTemplateSerializer(serializers.ModelSerializer):
|
||||
'attributes',
|
||||
]
|
||||
|
||||
def get_category_display(self, obj):
|
||||
return obj.get_category_display()
|
||||
|
||||
def get_card_type_display(self, obj):
|
||||
return obj.get_card_type_display()
|
||||
|
||||
@ -438,13 +421,23 @@ class MobileClothingTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_attributes(self, obj):
|
||||
try:
|
||||
if hasattr(obj, 'clothing_attrs'):
|
||||
return ClothingAttributesSerializer(obj.clothing_attrs).data
|
||||
attr_map = {
|
||||
'clothing': ('clothing_attrs', ClothingAttributesSerializer),
|
||||
'prop': ('prop_attrs', PropAttributesSerializer),
|
||||
'song': ('song_attrs', SongAttributesSerializer),
|
||||
'dance': ('dance_attrs', DanceAttributesSerializer),
|
||||
'decoration': ('decoration_attrs', DecorationAttributesSerializer),
|
||||
}
|
||||
if obj.category in attr_map:
|
||||
attr_name, serializer_cls = attr_map[obj.category]
|
||||
if hasattr(obj, attr_name):
|
||||
return serializer_cls(getattr(obj, attr_name)).data
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# 带有专有属性的卡牌详情序列化器
|
||||
class CategoryCardDetailSerializer(CardDetailSerializer):
|
||||
"""Card detail serializer with category-specific attributes"""
|
||||
@ -465,8 +458,6 @@ class CategoryCardDetailSerializer(CardDetailSerializer):
|
||||
representation['attributes'] = SongAttributesSerializer(template.song_attrs).data
|
||||
elif template.category == 'dance' and hasattr(template, 'dance_attrs'):
|
||||
representation['attributes'] = DanceAttributesSerializer(template.dance_attrs).data
|
||||
elif template.category == 'furniture' and hasattr(template, 'furniture_attrs'):
|
||||
representation['attributes'] = FurnitureAttributesSerializer(template.furniture_attrs).data
|
||||
elif template.category == 'decoration' and hasattr(template, 'decoration_attrs'):
|
||||
representation['attributes'] = DecorationAttributesSerializer(template.decoration_attrs).data
|
||||
except Exception as e:
|
||||
|
||||
@ -11,6 +11,6 @@ urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('user/cards/', views.UserCardListView.as_view(), name='user-cards'),
|
||||
path('category/<str:category>/', views.CategoryCardTemplateListView.as_view(), name='category-templates'),
|
||||
path('mobile/clothing/', views.MobileClothingListView.as_view(), name='mobile-clothing-list'),
|
||||
path('mobile/products.json', views.MobileProductsDownloadView.as_view(), name='mobile-products-download'),
|
||||
]
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
import json
|
||||
from rest_framework import viewsets, status, generics, permissions
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from rest_framework.response import Response
|
||||
@ -27,9 +28,10 @@ from .serializers import (
|
||||
CardSerializer, CardDetailSerializer, CardBatchSerializer, CardUsageLogSerializer,
|
||||
CardScanSerializer, CardUseSerializer, CardBatchGenerateSerializer,
|
||||
CardTemplatePublishSerializer, CardBatchPublishSerializer, CardBatchManufactureSerializer,
|
||||
CategoryTemplateSerializer, CategoryCardDetailSerializer, MobileClothingTemplateSerializer,
|
||||
CategoryTemplateSerializer, CategoryCardDetailSerializer,
|
||||
MobileProductSerializer,
|
||||
ClothingAttributesSerializer, PropAttributesSerializer, SongAttributesSerializer,
|
||||
DanceAttributesSerializer, FurnitureAttributesSerializer, DecorationAttributesSerializer
|
||||
DanceAttributesSerializer, DecorationAttributesSerializer
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -236,13 +238,7 @@ class CardTemplateViewSet(viewsets.ModelViewSet):
|
||||
dance_serializer = DanceAttributesSerializer(data=dance_data)
|
||||
dance_serializer.is_valid(raise_exception=True)
|
||||
dance_serializer.save(template=template)
|
||||
# 家具
|
||||
if category == 'furniture' and 'furniture_attributes' in request.data:
|
||||
furniture_data = request.data['furniture_attributes']
|
||||
furniture_serializer = FurnitureAttributesSerializer(data=furniture_data)
|
||||
furniture_serializer.is_valid(raise_exception=True)
|
||||
furniture_serializer.save(template=template)
|
||||
# 装饰
|
||||
# 家居装饰
|
||||
if category == 'decoration' and 'decoration_attributes' in request.data:
|
||||
decoration_data = request.data['decoration_attributes']
|
||||
decoration_serializer = DecorationAttributesSerializer(data=decoration_data)
|
||||
@ -1022,64 +1018,70 @@ class CategoryCardTemplateListView(generics.ListAPIView):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
|
||||
class MobileClothingListView(generics.ListAPIView):
|
||||
class MobileProductsDownloadView(generics.GenericAPIView):
|
||||
"""
|
||||
手机端服装列表接口
|
||||
手机端产品数据下载接口
|
||||
|
||||
仅返回已发布的服装模板,供手机端展示。
|
||||
支持按稀有度、类型筛选和关键词搜索。
|
||||
将所有已发布的产品(服装、道具、歌曲、舞蹈、家居装饰、食物)
|
||||
整合为一个 JSON 文件供手机端下载。
|
||||
"""
|
||||
serializer_class = MobileClothingTemplateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
authentication_classes = [RedisTokenAuthentication]
|
||||
tags = ['手机端']
|
||||
permission_classes = []
|
||||
authentication_classes = []
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = CardTemplate.objects.filter(
|
||||
category='clothing',
|
||||
def get(self, request):
|
||||
from food_app.models import Food
|
||||
from food_app.serializers import FoodSerializer
|
||||
|
||||
templates = CardTemplate.objects.filter(
|
||||
status='published',
|
||||
).order_by('-published_at')
|
||||
).order_by('category', '-published_at')
|
||||
|
||||
# 按稀有度筛选
|
||||
rarity = self.request.query_params.get('rarity')
|
||||
if rarity:
|
||||
queryset = queryset.filter(rarity=rarity)
|
||||
categories = ['clothing', 'prop', 'song', 'dance', 'decoration']
|
||||
category_labels = {
|
||||
'clothing': '服装',
|
||||
'prop': '道具',
|
||||
'song': '歌曲',
|
||||
'dance': '舞蹈',
|
||||
'decoration': '家居装饰',
|
||||
}
|
||||
|
||||
# 按类型筛选
|
||||
card_type = self.request.query_params.get('card_type')
|
||||
if card_type:
|
||||
queryset = queryset.filter(card_type=card_type)
|
||||
def normalize_image_url(items):
|
||||
"""统一 image_url 字段:有值则保留,空值设为 null;删除多余的 image 字段"""
|
||||
for item in items:
|
||||
# 兼容 food_app 使用 image 而非 image_url
|
||||
if 'image_url' not in item and 'image' in item:
|
||||
item['image_url'] = item['image']
|
||||
# 删除原始 image 字段,只保留 image_url
|
||||
item.pop('image', None)
|
||||
# 空字符串统一为 null
|
||||
if not item.get('image_url'):
|
||||
item['image_url'] = None
|
||||
return items
|
||||
|
||||
# 关键词搜索
|
||||
search = self.request.query_params.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(name__icontains=search)
|
||||
data = {}
|
||||
for cat in categories:
|
||||
cat_templates = templates.filter(category=cat)
|
||||
if cat_templates.exists():
|
||||
serializer = MobileProductSerializer(cat_templates, many=True)
|
||||
items = normalize_image_url(serializer.data)
|
||||
data[cat] = {
|
||||
'label': category_labels.get(cat, cat),
|
||||
'count': cat_templates.count(),
|
||||
'items': items,
|
||||
}
|
||||
|
||||
return queryset
|
||||
# 添加食物数据(来自独立的 food_app 模块)
|
||||
published_foods = Food.objects.filter(status='published').order_by('-published_at')
|
||||
if published_foods.exists():
|
||||
food_serializer = FoodSerializer(published_foods, many=True)
|
||||
items = normalize_image_url(food_serializer.data)
|
||||
data['food'] = {
|
||||
'label': '食物',
|
||||
'count': published_foods.count(),
|
||||
'items': items,
|
||||
}
|
||||
|
||||
@swagger_schema(
|
||||
responses={
|
||||
200: openapi.Response('查询成功', schema=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'count': openapi.Schema(type=openapi.TYPE_INTEGER, description='总数'),
|
||||
'next': openapi.Schema(type=openapi.TYPE_STRING, description='下一页URL', nullable=True),
|
||||
'previous': openapi.Schema(type=openapi.TYPE_STRING, description='上一页URL', nullable=True),
|
||||
'results': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(type=openapi.TYPE_OBJECT, description='服装模板'),
|
||||
),
|
||||
},
|
||||
)),
|
||||
},
|
||||
operation_description="获取已发布的服装列表(手机端专用)",
|
||||
tags=['手机端'],
|
||||
manual_parameters=[
|
||||
openapi.Parameter('rarity', openapi.IN_QUERY, description="按稀有度筛选 (common/uncommon/rare/epic/legendary/limited)", type=openapi.TYPE_STRING, required=False),
|
||||
openapi.Parameter('card_type', openapi.IN_QUERY, description="按类型筛选 (regular/seasonal/event)", type=openapi.TYPE_STRING, required=False),
|
||||
openapi.Parameter('search', openapi.IN_QUERY, description="按名称搜索", type=openapi.TYPE_STRING, required=False),
|
||||
],
|
||||
security=[{'Bearer': []}]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
content = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
response = HttpResponse(content, content_type='application/json; charset=utf-8')
|
||||
response['Content-Disposition'] = 'attachment; filename="products.json"'
|
||||
return response
|
||||
|
||||
@ -110,7 +110,7 @@ class StandardResponseMiddleware(MiddlewareMixin):
|
||||
|
||||
# 确保content也更新
|
||||
try:
|
||||
response.content = json.dumps(response.data)
|
||||
response.content = json.dumps(response.data, ensure_ascii=False, indent=2)
|
||||
response['Content-Type'] = 'application/json'
|
||||
except:
|
||||
pass
|
||||
|
||||
@ -281,6 +281,7 @@ REST_FRAMEWORK = {
|
||||
},
|
||||
'DEFAULT_PAGINATION_CLASS': 'common.pagination.CustomPageNumberPagination',
|
||||
'PAGE_SIZE': 10,
|
||||
'UNICODE_JSON': False,
|
||||
}
|
||||
|
||||
# Disable CSRF checks for API views
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user