feat: update card models, admin pages, and add migrations
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:
pmc 2026-03-26 16:38:48 +08:00
parent 55ca2cbdaf
commit c0fe1f502b
15 changed files with 555 additions and 349 deletions

View File

@ -153,7 +153,25 @@
"Bash(python -c \"from card.serializers import MobileClothingTemplateSerializer; print\\(''Serializer OK''\\)\")", "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.views import MobileClothingListView; print\\(''View OK''\\)\")",
"Bash(python -c \"from card.urls import urlpatterns; print\\(f''URLs OK, {len\\(urlpatterns\\)} routes''\\)\")", "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": [ "additionalDirectories": [
"C:\\Users\\admin\\.claude" "C:\\Users\\admin\\.claude"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { DashboardShell } from "@/components/dashboard-shell" import { DashboardShell } from "@/components/dashboard-shell"
import { DashboardHeader } from "@/components/dashboard-header" import { DashboardHeader } from "@/components/dashboard-header"
@ -9,149 +9,130 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" 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 { AddDanceDialog } from "@/components/dances/add-dance-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog" import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/hooks/use-toast" 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" import type { Dance } from "@/lib/api/types"
// 初始舞蹈数据 function formatDate(dateStr?: string): string {
const initialDances: Dance[] = [ if (!dateStr) return ""
{ try {
id: "1", return new Date(dateStr).toLocaleDateString("zh-CN", {
name: "千本樱", year: "numeric",
choreographer: "洛天依工作室", month: "2-digit",
duration: "3:45", day: "2-digit",
difficulty: "中等", })
videoUrl: "/placeholder.svg?height=300&width=400", } catch {
coverUrl: "/placeholder.svg?height=300&width=400", return dateStr
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",
},
]
export default function DancesPage() { export default function DancesPage() {
const { toast } = useToast() const { toast } = useToast()
const router = useRouter() const router = useRouter()
const [dances, setDances] = useState<Dance[]>(initialDances) const [dances, setDances] = useState<Dance[]>([])
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [danceToEdit, setDanceToEdit] = useState<Dance | undefined>(undefined) 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 fetchDances = useCallback(async () => {
const filteredDances = dances.filter( setIsLoading(true)
(dance) => try {
dance.name.toLowerCase().includes(searchTerm.toLowerCase()) || const result = await getDances(currentPage, itemsPerPage, searchTerm)
dance.id.toLowerCase().includes(searchTerm.toLowerCase()) || if (result.data) {
(dance.choreographer && dance.choreographer.toLowerCase().includes(searchTerm.toLowerCase())) || setDances(result.data)
(dance.category && dance.category.toLowerCase().includes(searchTerm.toLowerCase())) || setTotalItems(result.pagination?.totalItems || 0)
(dance.tags && dance.tags.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase()))),
)
const totalPages = Math.ceil(filteredDances.length / itemsPerPage)
const paginatedDances = filteredDances.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
// 处理添加舞蹈
const handleAddDance = (newDance: Dance) => {
setDances((prevDances) => [...prevDances, newDance])
toast({
title: "添加成功",
description: `舞蹈 ${newDance.name} 已成功添加`,
})
} }
} catch (error) {
toast({
title: "加载失败",
description: "无法获取舞蹈列表",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}, [currentPage, searchTerm, itemsPerPage, toast])
// 处理编辑舞蹈 useEffect(() => {
const handleEditDance = (updatedDance: Dance) => { fetchDances()
setDances((prevDances) => prevDances.map((dance) => (dance.id === updatedDance.id ? updatedDance : dance))) }, [fetchDances])
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 处理添加/编辑舞蹈后刷新列表
const handleDanceSaved = () => {
setDanceToEdit(undefined) setDanceToEdit(undefined)
setIsAddDialogOpen(false) setIsAddDialogOpen(false)
fetchDances()
toast({ toast({
title: "更新成功", title: "保存成功",
description: `舞蹈 ${updatedDance.name} 已成功更新`, description: "舞蹈数据已更新",
}) })
} }
// 处理删除舞蹈 // 处理删除舞蹈
const handleDeleteDance = () => { const handleDeleteDance = async (danceId: string, danceName: string) => {
if (!danceToDelete) return try {
await deleteDance(danceId)
setDances((prevDances) => prevDances.filter((dance) => dance.id !== danceToDelete.id)) await fetchDances()
setDanceToDelete(null)
setIsDeleteDialogOpen(false)
toast({ toast({
title: "删除成功", title: "删除成功",
description: `舞蹈 ${danceToDelete.name} 已成功删除`, description: `舞蹈 ${danceName} 已成功删除`,
variant: "destructive", 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",
})
}
} }
// 打开编辑对话框 // 打开编辑对话框
@ -165,12 +146,6 @@ export default function DancesPage() {
router.push(`/dances/${dance.id}`) router.push(`/dances/${dance.id}`)
} }
// 打开删除确认对话框
const openDeleteDialog = (dance: Dance) => {
setDanceToDelete(dance)
setIsDeleteDialogOpen(true)
}
return ( return (
<DashboardShell> <DashboardShell>
<DashboardHeader heading="舞蹈管理" text="管理洛天依的舞蹈库"> <DashboardHeader heading="舞蹈管理" text="管理洛天依的舞蹈库">
@ -197,7 +172,7 @@ export default function DancesPage() {
value={searchTerm} value={searchTerm}
onChange={(e) => { onChange={(e) => {
setSearchTerm(e.target.value) setSearchTerm(e.target.value)
setCurrentPage(1) // 重置到第一页 setCurrentPage(1)
}} }}
/> />
</div> </div>
@ -213,6 +188,11 @@ export default function DancesPage() {
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-8 w-8 animate-spin text-purple-500" />
</div>
) : (
<Table> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">
<TableRow> <TableRow>
@ -223,11 +203,13 @@ export default function DancesPage() {
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedDances.map((dance) => ( {dances.map((dance) => (
<TableRow key={dance.id} className="hover:bg-gray-50 transition-colors"> <TableRow key={dance.id} className="hover:bg-gray-50 transition-colors">
<TableCell> <TableCell>
<div className="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center text-purple-600"> <div className="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">
@ -243,7 +225,7 @@ export default function DancesPage() {
className={ className={
dance.difficulty === "初级" dance.difficulty === "初级"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: dance.difficulty === "中等" : dance.difficulty === "中等" || dance.difficulty === "中级"
? "bg-blue-100 text-blue-800" ? "bg-blue-100 text-blue-800"
: dance.difficulty === "中高级" : dance.difficulty === "中高级"
? "bg-yellow-100 text-yellow-800" ? "bg-yellow-100 text-yellow-800"
@ -259,6 +241,20 @@ export default function DancesPage() {
</TableCell> </TableCell>
<TableCell>{dance.category || "未分类"}</TableCell> <TableCell>{dance.category || "未分类"}</TableCell>
<TableCell>{dance.duration || "未知"}</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"> <TableCell className="text-right">
<div className="flex justify-end space-x-1"> <div className="flex justify-end space-x-1">
<Button <Button
@ -270,6 +266,32 @@ export default function DancesPage() {
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
<span className="sr-only"></span> <span className="sr-only"></span>
</Button> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -279,34 +301,35 @@ export default function DancesPage() {
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
<span className="sr-only"></span> <span className="sr-only"></span>
</Button> </Button>
<Button
variant="ghost" <DeleteConfirmationDialog
size="icon" title="删除舞蹈"
className="hover:bg-red-50 hover:text-red-600" description="此操作将永久删除该舞蹈及其所有相关数据。"
onClick={() => openDeleteDialog(dance)} itemName={dance.name}
> onDelete={() => handleDeleteDance(dance.id, dance.name)}
<Trash2 className="h-4 w-4" /> />
<span className="sr-only"></span> </>
</Button> )}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{paginatedDances.length === 0 && ( {dances.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-24 text-center"> <TableCell colSpan={10} className="h-24 text-center">
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
)}
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <CardFooter className="flex justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{paginatedDances.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}- {dances.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}-
{Math.min(currentPage * itemsPerPage, filteredDances.length)} {filteredDances.length} {Math.min(currentPage * itemsPerPage, totalItems)} {totalItems}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
@ -335,18 +358,10 @@ export default function DancesPage() {
<AddDanceDialog <AddDanceDialog
open={isAddDialogOpen} open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen} onOpenChange={setIsAddDialogOpen}
onDanceAdded={danceToEdit ? handleEditDance : handleAddDance} onDanceAdded={handleDanceSaved}
editDance={danceToEdit} editDance={danceToEdit}
/> />
{/* 删除确认对话框 */}
<DeleteConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteDance}
title="删除舞蹈"
description={`确定要删除舞蹈 "${danceToDelete?.name}" 吗?此操作无法撤销。`}
/>
</DashboardShell> </DashboardShell>
) )
} }

View File

@ -8,13 +8,14 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" 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 { AddSongDialog } from "@/components/songs/add-song-dialog"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog" import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog" import { PublishConfirmationDialog } from "@/components/publish-confirmation-dialog"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import Link from "next/link" 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 } from "@/lib/api/types"
import type { Song as ComponentSong } from "@/components/songs/song-detail-dialog" import type { Song as ComponentSong } from "@/components/songs/song-detail-dialog"
import { apiSongToComponentSong, componentSongToApiSong } from "@/lib/api/adapters" import { apiSongToComponentSong, componentSongToApiSong } from "@/lib/api/adapters"
@ -42,7 +43,7 @@ export default function SongsPage() {
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const currentSongUrlRef = useRef<string | null>(null) const currentSongUrlRef = useRef<string | null>(null)
const itemsPerPage = 5 const itemsPerPage = 10
// 加载歌曲数据 // 加载歌曲数据
useEffect(() => { useEffect(() => {
@ -197,22 +198,47 @@ export default function SongsPage() {
// 处理删除歌曲 // 处理删除歌曲
const handleDeleteSong = async (songId: string) => { const handleDeleteSong = async (songId: string) => {
try {
// 如果正在播放该歌曲,先停止播放 // 如果正在播放该歌曲,先停止播放
if (playingSongId === songId && audioRef.current) { if (playingSongId === songId && audioRef.current) {
audioRef.current.pause() audioRef.current.pause()
setPlayingSongId(null) setPlayingSongId(null)
} }
// 模拟API请求 await deleteSong(songId)
await new Promise((resolve) => setTimeout(resolve, 1000)) setRefreshTrigger(prev => prev + 1)
setSongs((prevSongs) => prevSongs.filter((song) => song.id !== songId))
setRefreshTrigger(prev => prev + 1) // 触发刷新
toast({ toast({
title: "删除成功", title: "删除成功",
description: "歌曲已成功删除", description: "歌曲已成功删除",
variant: "destructive", variant: "destructive",
}) })
} catch (error) {
console.error("删除歌曲失败:", error)
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> <TableCell>
<Badge <Badge
className={ 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} {song.status}
@ -516,7 +546,30 @@ export default function SongsPage() {
</Link> </Link>
</Button> </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 <Button
variant="ghost" variant="ghost"
@ -527,13 +580,6 @@ export default function SongsPage() {
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<PublishConfirmationDialog
title="发布歌曲"
description="发布后该歌曲将可被用户使用"
itemName={song.name}
onPublish={() => handlePublishSong(song.id)}
/>
<DeleteConfirmationDialog <DeleteConfirmationDialog
title="删除歌曲" title="删除歌曲"
description="此操作将永久删除该歌曲及其所有相关数据。" description="此操作将永久删除该歌曲及其所有相关数据。"

View File

@ -27,7 +27,7 @@ export default function UsersPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const { toast } = useToast() const { toast } = useToast()
const itemsPerPage = 5 const itemsPerPage = 10
// 获取用户列表 // 获取用户列表
const fetchUsers = async () => { const fetchUsers = async () => {

View File

@ -17,6 +17,10 @@ function mapBackendDance(item: any): Dance {
motionFile: item.model_url || "", motionFile: item.model_url || "",
category: attrs.style || "", category: attrs.style || "",
tags: [], 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 || "", createdAt: item.created_at || "",
updatedAt: item.updated_at || "", updatedAt: item.updated_at || "",
} }
@ -119,3 +123,17 @@ export async function deleteDance(id: string) {
return handleApiError(error) 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) }
}

View File

@ -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[]> => { export const getSongBatches = async (songId: string): Promise<SongBatch[]> => {
try { try {

View File

@ -162,6 +162,10 @@ export interface Dance {
motionFile?: string // 动作文件 motionFile?: string // 动作文件
category?: string category?: string
tags?: string[] tags?: string[]
status?: string
statusDisplay?: string
publishedAt?: string
activeCardsCount?: number
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
} }

View File

@ -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),
]

View File

@ -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='类别'),
),
]

View File

@ -18,8 +18,7 @@ class CardTemplate(models.Model):
('prop', '道具'), ('prop', '道具'),
('song', '音乐'), ('song', '音乐'),
('dance', '舞蹈'), ('dance', '舞蹈'),
('furniture', '家具'), ('decoration', '家居装饰'),
('decoration', '装饰'),
] ]
RARITY_CHOICES = [ RARITY_CHOICES = [

View File

@ -2,7 +2,7 @@ from rest_framework import serializers
from .models import ( from .models import (
CardTemplate, Card, CardBatch, CardUsageLog, CardTemplate, Card, CardBatch, CardUsageLog,
ClothingAttributes, PropAttributes, SongAttributes, 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 DecorationAttributesSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = DecorationAttributes model = DecorationAttributes
@ -72,7 +63,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
prop_attributes = PropAttributesSerializer(write_only=True, required=False) prop_attributes = PropAttributesSerializer(write_only=True, required=False)
song_attributes = SongAttributesSerializer(write_only=True, required=False) song_attributes = SongAttributesSerializer(write_only=True, required=False)
dance_attributes = DanceAttributesSerializer(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) decoration_attributes = DecorationAttributesSerializer(write_only=True, required=False)
class Meta: class Meta:
@ -84,7 +74,7 @@ class CardTemplateSerializer(serializers.ModelSerializer):
'status', 'status_display', 'published_at', 'price', 'status', 'status_display', 'published_at', 'price',
'created_at', 'updated_at', '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'] 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('prop_attributes', None)
validated_data.pop('song_attributes', None) validated_data.pop('song_attributes', None)
validated_data.pop('dance_attributes', None) validated_data.pop('dance_attributes', None)
validated_data.pop('furniture_attributes', None)
validated_data.pop('decoration_attributes', None) validated_data.pop('decoration_attributes', None)
return super().create(validated_data) return super().create(validated_data)
@ -123,8 +112,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data
elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'): elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'):
representation['attributes'] = DanceAttributesSerializer(instance.dance_attrs).data 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'): elif instance.category == 'decoration' and hasattr(instance, 'decoration_attrs'):
representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data
except Exception: except Exception:
@ -137,7 +124,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
prop_data = validated_data.pop('prop_attributes', None) prop_data = validated_data.pop('prop_attributes', None)
song_data = validated_data.pop('song_attributes', None) song_data = validated_data.pop('song_attributes', None)
dance_data = validated_data.pop('dance_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) decoration_data = validated_data.pop('decoration_attributes', None)
# 更新主表 # 更新主表
@ -160,10 +146,6 @@ class CardTemplateSerializer(serializers.ModelSerializer):
for k, v in dance_data.items(): for k, v in dance_data.items():
setattr(instance.dance_attrs, k, v) setattr(instance.dance_attrs, k, v)
instance.dance_attrs.save() 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'): if decoration_data is not None and hasattr(instance, 'decoration_attrs'):
for k, v in decoration_data.items(): for k, v in decoration_data.items():
setattr(instance.decoration_attrs, k, v) setattr(instance.decoration_attrs, k, v)
@ -200,8 +182,6 @@ class CardTemplateDetailSerializer(CardTemplateSerializer):
representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data
elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'): elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'):
representation['attributes'] = DanceAttributesSerializer(instance.dance_attrs).data 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'): elif instance.category == 'decoration' and hasattr(instance, 'decoration_attrs'):
representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data
except Exception as e: except Exception as e:
@ -391,8 +371,6 @@ class CategoryTemplateSerializer(CardTemplateDetailSerializer):
representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data representation['attributes'] = SongAttributesSerializer(instance.song_attrs).data
elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'): elif instance.category == 'dance' and hasattr(instance, 'dance_attrs'):
representation['attributes'] = DanceAttributesSerializer(instance.dance_attrs).data 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'): elif instance.category == 'decoration' and hasattr(instance, 'decoration_attrs'):
representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data representation['attributes'] = DecorationAttributesSerializer(instance.decoration_attrs).data
except Exception as e: except Exception as e:
@ -402,8 +380,9 @@ class CategoryTemplateSerializer(CardTemplateDetailSerializer):
return representation return representation
class MobileClothingTemplateSerializer(serializers.ModelSerializer): class MobileProductSerializer(serializers.ModelSerializer):
"""手机端服装模板序列化器 - 只返回已发布服装的必要字段""" """手机端通用产品序列化器 - 适用于所有分类"""
category_display = serializers.SerializerMethodField()
card_type_display = serializers.SerializerMethodField() card_type_display = serializers.SerializerMethodField()
rarity_display = serializers.SerializerMethodField() rarity_display = serializers.SerializerMethodField()
status_display = serializers.SerializerMethodField() status_display = serializers.SerializerMethodField()
@ -415,6 +394,7 @@ class MobileClothingTemplateSerializer(serializers.ModelSerializer):
model = CardTemplate model = CardTemplate
fields = [ fields = [
'id', 'name', 'description', 'id', 'name', 'description',
'category', 'category_display',
'card_type', 'card_type_display', 'card_type', 'card_type_display',
'rarity', 'rarity_display', 'rarity', 'rarity_display',
'image_url', 'image_url',
@ -424,6 +404,9 @@ class MobileClothingTemplateSerializer(serializers.ModelSerializer):
'attributes', 'attributes',
] ]
def get_category_display(self, obj):
return obj.get_category_display()
def get_card_type_display(self, obj): def get_card_type_display(self, obj):
return obj.get_card_type_display() return obj.get_card_type_display()
@ -438,13 +421,23 @@ class MobileClothingTemplateSerializer(serializers.ModelSerializer):
def get_attributes(self, obj): def get_attributes(self, obj):
try: try:
if hasattr(obj, 'clothing_attrs'): attr_map = {
return ClothingAttributesSerializer(obj.clothing_attrs).data '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: except Exception:
pass pass
return None return None
# 带有专有属性的卡牌详情序列化器 # 带有专有属性的卡牌详情序列化器
class CategoryCardDetailSerializer(CardDetailSerializer): class CategoryCardDetailSerializer(CardDetailSerializer):
"""Card detail serializer with category-specific attributes""" """Card detail serializer with category-specific attributes"""
@ -465,8 +458,6 @@ class CategoryCardDetailSerializer(CardDetailSerializer):
representation['attributes'] = SongAttributesSerializer(template.song_attrs).data representation['attributes'] = SongAttributesSerializer(template.song_attrs).data
elif template.category == 'dance' and hasattr(template, 'dance_attrs'): elif template.category == 'dance' and hasattr(template, 'dance_attrs'):
representation['attributes'] = DanceAttributesSerializer(template.dance_attrs).data 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'): elif template.category == 'decoration' and hasattr(template, 'decoration_attrs'):
representation['attributes'] = DecorationAttributesSerializer(template.decoration_attrs).data representation['attributes'] = DecorationAttributesSerializer(template.decoration_attrs).data
except Exception as e: except Exception as e:

View File

@ -11,6 +11,6 @@ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('user/cards/', views.UserCardListView.as_view(), name='user-cards'), path('user/cards/', views.UserCardListView.as_view(), name='user-cards'),
path('category/<str:category>/', views.CategoryCardTemplateListView.as_view(), name='category-templates'), 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'),
] ]

View File

@ -1,7 +1,8 @@
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.db import transaction 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 import viewsets, status, generics, permissions
from rest_framework.decorators import api_view, permission_classes, action from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.response import Response from rest_framework.response import Response
@ -27,9 +28,10 @@ from .serializers import (
CardSerializer, CardDetailSerializer, CardBatchSerializer, CardUsageLogSerializer, CardSerializer, CardDetailSerializer, CardBatchSerializer, CardUsageLogSerializer,
CardScanSerializer, CardUseSerializer, CardBatchGenerateSerializer, CardScanSerializer, CardUseSerializer, CardBatchGenerateSerializer,
CardTemplatePublishSerializer, CardBatchPublishSerializer, CardBatchManufactureSerializer, CardTemplatePublishSerializer, CardBatchPublishSerializer, CardBatchManufactureSerializer,
CategoryTemplateSerializer, CategoryCardDetailSerializer, MobileClothingTemplateSerializer, CategoryTemplateSerializer, CategoryCardDetailSerializer,
MobileProductSerializer,
ClothingAttributesSerializer, PropAttributesSerializer, SongAttributesSerializer, ClothingAttributesSerializer, PropAttributesSerializer, SongAttributesSerializer,
DanceAttributesSerializer, FurnitureAttributesSerializer, DecorationAttributesSerializer DanceAttributesSerializer, DecorationAttributesSerializer
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -236,13 +238,7 @@ class CardTemplateViewSet(viewsets.ModelViewSet):
dance_serializer = DanceAttributesSerializer(data=dance_data) dance_serializer = DanceAttributesSerializer(data=dance_data)
dance_serializer.is_valid(raise_exception=True) dance_serializer.is_valid(raise_exception=True)
dance_serializer.save(template=template) 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: if category == 'decoration' and 'decoration_attributes' in request.data:
decoration_data = request.data['decoration_attributes'] decoration_data = request.data['decoration_attributes']
decoration_serializer = DecorationAttributesSerializer(data=decoration_data) decoration_serializer = DecorationAttributesSerializer(data=decoration_data)
@ -1022,64 +1018,70 @@ class CategoryCardTemplateListView(generics.ListAPIView):
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
class MobileClothingListView(generics.ListAPIView): class MobileProductsDownloadView(generics.GenericAPIView):
""" """
手机端服装列表接口 手机端产品数据下载接口
仅返回已发布的服装模板供手机端展示 将所有已发布的产品服装道具歌曲舞蹈家居装饰食物
支持按稀有度类型筛选和关键词搜索 整合为一个 JSON 文件供手机端下载
""" """
serializer_class = MobileClothingTemplateSerializer permission_classes = []
permission_classes = [IsAuthenticated] authentication_classes = []
authentication_classes = [RedisTokenAuthentication]
tags = ['手机端']
def get_queryset(self): def get(self, request):
queryset = CardTemplate.objects.filter( from food_app.models import Food
category='clothing', from food_app.serializers import FoodSerializer
templates = CardTemplate.objects.filter(
status='published', status='published',
).order_by('-published_at') ).order_by('category', '-published_at')
# 按稀有度筛选 categories = ['clothing', 'prop', 'song', 'dance', 'decoration']
rarity = self.request.query_params.get('rarity') category_labels = {
if rarity: 'clothing': '服装',
queryset = queryset.filter(rarity=rarity) 'prop': '道具',
'song': '歌曲',
'dance': '舞蹈',
'decoration': '家居装饰',
}
# 按类型筛选 def normalize_image_url(items):
card_type = self.request.query_params.get('card_type') """统一 image_url 字段:有值则保留,空值设为 null删除多余的 image 字段"""
if card_type: for item in items:
queryset = queryset.filter(card_type=card_type) # 兼容 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
# 关键词搜索 data = {}
search = self.request.query_params.get('search') for cat in categories:
if search: cat_templates = templates.filter(category=cat)
queryset = queryset.filter(name__icontains=search) 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( content = json.dumps(data, ensure_ascii=False, indent=2)
responses={ response = HttpResponse(content, content_type='application/json; charset=utf-8')
200: openapi.Response('查询成功', schema=openapi.Schema( response['Content-Disposition'] = 'attachment; filename="products.json"'
type=openapi.TYPE_OBJECT, return response
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)

View File

@ -110,7 +110,7 @@ class StandardResponseMiddleware(MiddlewareMixin):
# 确保content也更新 # 确保content也更新
try: try:
response.content = json.dumps(response.data) response.content = json.dumps(response.data, ensure_ascii=False, indent=2)
response['Content-Type'] = 'application/json' response['Content-Type'] = 'application/json'
except: except:
pass pass

View File

@ -281,6 +281,7 @@ REST_FRAMEWORK = {
}, },
'DEFAULT_PAGINATION_CLASS': 'common.pagination.CustomPageNumberPagination', 'DEFAULT_PAGINATION_CLASS': 'common.pagination.CustomPageNumberPagination',
'PAGE_SIZE': 10, 'PAGE_SIZE': 10,
'UNICODE_JSON': False,
} }
# Disable CSRF checks for API views # Disable CSRF checks for API views