From c0fe1f502be741f356611f513d0d749a4962e333 Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Thu, 26 Mar 2026 16:38:48 +0800 Subject: [PATCH] feat: update card models, admin pages, and add migrations - Update card models, serializers, views and URLs - Update dances, songs, users admin pages and API modules - Add card migrations (merge furniture into decoration) - Update middleware and settings Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 20 +- qy-lty-admin/app/dances/page.tsx | 461 +++++++++--------- qy-lty-admin/app/songs/page.tsx | 100 +++- qy-lty-admin/app/users/page.tsx | 2 +- qy-lty-admin/lib/api/dances.ts | 18 + qy-lty-admin/lib/api/songs.ts | 18 + qy-lty-admin/lib/api/types.ts | 4 + .../0013_merge_furniture_into_decoration.py | 66 +++ ...egory_alter_cardbatch_category_and_more.py | 28 ++ qy_lty/card/models.py | 3 +- qy_lty/card/serializers.py | 55 +-- qy_lty/card/urls.py | 2 +- qy_lty/card/views.py | 124 ++--- qy_lty/common/middleware.py | 2 +- qy_lty/qy_lty/settings.py | 1 + 15 files changed, 555 insertions(+), 349 deletions(-) create mode 100644 qy_lty/card/migrations/0013_merge_furniture_into_decoration.py create mode 100644 qy_lty/card/migrations/0014_alter_card_category_alter_cardbatch_category_and_more.py diff --git a/.claude/settings.json b/.claude/settings.json index 4f6806e..58f630f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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" diff --git a/qy-lty-admin/app/dances/page.tsx b/qy-lty-admin/app/dances/page.tsx index e8dc1dc..bbcd9ea 100644 --- a/qy-lty-admin/app/dances/page.tsx +++ b/qy-lty-admin/app/dances/page.tsx @@ -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(initialDances) + const [dances, setDances] = useState([]) 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(undefined) - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [danceToDelete, setDanceToDelete] = useState(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 ( @@ -197,7 +172,7 @@ export default function DancesPage() { value={searchTerm} onChange={(e) => { setSearchTerm(e.target.value) - setCurrentPage(1) // 重置到第一页 + setCurrentPage(1) }} /> @@ -213,100 +188,148 @@ export default function DancesPage() { 管理洛天依可以表演的舞蹈 - - - - - ID - 舞蹈名称 - 编舞者 - 难度 - 分类 - 时长 - 操作 - - - - {paginatedDances.map((dance) => ( - - -
-
-
- {dance.id} - {dance.name} - {dance.choreographer || "未知"} - - - {dance.difficulty || "未知"} - - - {dance.category || "未分类"} - {dance.duration || "未知"} - -
- - - -
-
-
- ))} - - {paginatedDances.length === 0 && ( + {isLoading ? ( +
+ +
+ ) : ( +
+ - - 没有找到匹配的舞蹈 - + + ID + 舞蹈名称 + 编舞者 + 难度 + 分类 + 时长 + 发布日期 + 状态 + 操作 - )} - -
+ + + {dances.map((dance) => ( + + +
+
+
+ {dance.id} + {dance.name} + {dance.choreographer || "未知"} + + + {dance.difficulty || "未知"} + + + {dance.category || "未分类"} + {dance.duration || "未知"} + {formatDate(dance.publishedAt) || "-"} + + + {dance.status || "草稿"} + + + +
+ + + {/* 草稿状态:显示发布按钮 */} + {dance.status === "草稿" && ( + handlePublishDance(dance.id, dance.name)} + /> + )} + + {/* 已发布状态:显示归档按钮 */} + {dance.status === "已发布" && ( + + )} + + {(dance.status !== "已发布" || isSuperUser()) && ( + <> + + + handleDeleteDance(dance.id, dance.name)} + /> + + )} +
+
+
+ ))} + + {dances.length === 0 && ( + + + 没有找到匹配的舞蹈 + + + )} +
+ + )}
- 显示 {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} 个舞蹈
- {song.status !== "已发布" && ( + {/* 草稿状态:显示发布按钮 */} + {song.status !== "已发布" && song.status !== "已归档" && ( + handlePublishSong(song.id)} + /> + )} + + {/* 已发布状态:显示归档按钮 */} + {song.status === "已发布" && ( + + )} + + {(song.status !== "已发布" || isSuperUser()) && ( <> - handlePublishSong(song.id)} - /> - { diff --git a/qy-lty-admin/lib/api/dances.ts b/qy-lty-admin/lib/api/dances.ts index adb86b6..ee6ea99 100644 --- a/qy-lty-admin/lib/api/dances.ts +++ b/qy-lty-admin/lib/api/dances.ts @@ -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) } +} diff --git a/qy-lty-admin/lib/api/songs.ts b/qy-lty-admin/lib/api/songs.ts index ffa2a61..0782b3a 100644 --- a/qy-lty-admin/lib/api/songs.ts +++ b/qy-lty-admin/lib/api/songs.ts @@ -227,6 +227,24 @@ export const publishSong = async (id: string): Promise => { } }; +// 归档歌曲 +export const archiveSong = async (id: string): Promise => { + 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 => { try { diff --git a/qy-lty-admin/lib/api/types.ts b/qy-lty-admin/lib/api/types.ts index 766b7a1..f76780e 100644 --- a/qy-lty-admin/lib/api/types.ts +++ b/qy-lty-admin/lib/api/types.ts @@ -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 } diff --git a/qy_lty/card/migrations/0013_merge_furniture_into_decoration.py b/qy_lty/card/migrations/0013_merge_furniture_into_decoration.py new file mode 100644 index 0000000..9897a7b --- /dev/null +++ b/qy_lty/card/migrations/0013_merge_furniture_into_decoration.py @@ -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), + ] diff --git a/qy_lty/card/migrations/0014_alter_card_category_alter_cardbatch_category_and_more.py b/qy_lty/card/migrations/0014_alter_card_category_alter_cardbatch_category_and_more.py new file mode 100644 index 0000000..869e097 --- /dev/null +++ b/qy_lty/card/migrations/0014_alter_card_category_alter_cardbatch_category_and_more.py @@ -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='类别'), + ), + ] diff --git a/qy_lty/card/models.py b/qy_lty/card/models.py index b245c2e..e2addf4 100644 --- a/qy_lty/card/models.py +++ b/qy_lty/card/models.py @@ -18,8 +18,7 @@ class CardTemplate(models.Model): ('prop', '道具'), ('song', '音乐'), ('dance', '舞蹈'), - ('furniture', '家具'), - ('decoration', '装饰'), + ('decoration', '家居装饰'), ] RARITY_CHOICES = [ diff --git a/qy_lty/card/serializers.py b/qy_lty/card/serializers.py index bdeaa7c..f7c31aa 100644 --- a/qy_lty/card/serializers.py +++ b/qy_lty/card/serializers.py @@ -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,14 +182,12 @@ 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: # 处理相关属性不存在的情况 representation['attributes'] = None - + return representation @@ -391,19 +371,18 @@ 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: # Handle the case where related attributes don't exist representation['attributes'] = None - + 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: diff --git a/qy_lty/card/urls.py b/qy_lty/card/urls.py index c66ef47..1d415aa 100644 --- a/qy_lty/card/urls.py +++ b/qy_lty/card/urls.py @@ -11,6 +11,6 @@ urlpatterns = [ path('', include(router.urls)), path('user/cards/', views.UserCardListView.as_view(), name='user-cards'), path('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'), ] diff --git a/qy_lty/card/views.py b/qy_lty/card/views.py index d022ece..237b3da 100644 --- a/qy_lty/card/views.py +++ b/qy_lty/card/views.py @@ -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 diff --git a/qy_lty/common/middleware.py b/qy_lty/common/middleware.py index 8b25655..c55c278 100644 --- a/qy_lty/common/middleware.py +++ b/qy_lty/common/middleware.py @@ -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 diff --git a/qy_lty/qy_lty/settings.py b/qy_lty/qy_lty/settings.py index fe0fb8c..365f6c8 100644 --- a/qy_lty/qy_lty/settings.py +++ b/qy_lty/qy_lty/settings.py @@ -281,6 +281,7 @@ REST_FRAMEWORK = { }, 'DEFAULT_PAGINATION_CLASS': 'common.pagination.CustomPageNumberPagination', 'PAGE_SIZE': 10, + 'UNICODE_JSON': False, } # Disable CSRF checks for API views