feat: 预设素材库 - 4 套主题 × 4 张图 = 16 张 PNG

主题:classical 古典宫廷(王子/皇后/国王/小丑)
      modern    现代人物(小孩/女青年/男青年/小丑鱼)
      astronomy 天文(星星/月亮/太阳/黑洞)
      minimal   简笔符号(圆点/♀/♂/叉)

改动:
- 新增 LibraryAsset 模型(全局素材库,theme_id/role/asset_id 索引)
- 新增 seed_library 管理命令,用 Pillow 画 16 张 PNG 素材
- 新增 /api/projects/library/ 列表 API
- 新增 /api/projects/{pid}/library/{id}/apply/ 应用 API
  把预设素材复制到 projects/<pid>/joker 或 face_card 下,作为该牌位的素材
- AssetPanel 加 tab 切换:「我的素材」+「预设主题」,主题可筛选、点击套用到当前牌
- 修复 generate_card_png 的 joker asset 匹配 bug:which 应该是 card_key(含前缀)才能匹配 asset_key

设计要点:
- 预设素材只画上半身(y=0~150),下半留空,让系统的'自动对称'流水线正确工作
- 预设素材 PNG 200×300,深色描边 + 半透明浅色填充,在任意牌面背景上叠加都清晰
This commit is contained in:
Developer
2026-06-02 14:39:52 +08:00
parent 0a22a0c0d2
commit 5ca000b8ab
24 changed files with 815 additions and 47 deletions

View File

@@ -0,0 +1,114 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from django.conf import settings
from .models import LibraryAsset, Project, Asset
from .serializers import LibraryAssetSerializer
import os
@api_view(['GET'])
def library_list(request):
"""列出所有预设素材(按主题分组)"""
assets = LibraryAsset.objects.all()
serializer = LibraryAssetSerializer(assets, many=True, context={'request': request})
# 按 theme_id 分组
grouped = {}
for a in serializer.data:
a['file_url'] = f'{settings.MEDIA_URL}{a["file_path"]}'
grouped.setdefault(a['theme_id'], {
'theme_id': a['theme_id'],
'theme_name': a['theme_name'],
'description': a['description'],
'items': [],
})['items'].append(a)
return Response(list(grouped.values()))
@api_view(['GET'])
def library_themes(request):
"""返回所有主题的元信息(不含具体素材项,用于渲染主题筛选器)"""
themes = LibraryAsset.objects.values('theme_id', 'theme_name', 'description').distinct()
return Response(list(themes))
@api_view(['GET'])
def library_detail(request, pk):
"""单个预设素材的详情"""
try:
a = LibraryAsset.objects.get(pk=pk)
except LibraryAsset.DoesNotExist:
return Response({'error': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
data = LibraryAssetSerializer(a, context={'request': request}).data
data['file_url'] = f'{settings.MEDIA_URL}{data["file_path"]}'
return Response(data)
@api_view(['POST'])
def library_apply(request, project_pk, pk):
"""把预设素材应用到项目某张牌默认spade-J / joker-big 等)
请求体: { card_key?: 'spade-J' } 可选;不传则用预设素材的 role + 默认 spade
"""
try:
project = Project.objects.get(pk=project_pk)
lib = LibraryAsset.objects.get(pk=pk)
except (Project.DoesNotExist, LibraryAsset.DoesNotExist):
return Response({'error': 'not found'}, status=status.HTTP_404_NOT_FOUND)
card_key = request.data.get('card_key')
if not card_key:
# 默认: spade-{role} 或 joker-{which}
if lib.role == 'joker':
card_key = 'joker-big' # 默认应用到 big用户可换
else:
card_key = f'spade-{lib.role}'
# 决定 asset_type: J/Q/K -> face_cardjoker -> joker
asset_type = 'face_card' if lib.role in ('J', 'Q', 'K') else 'joker'
asset_key = card_key
# 删除该项目同 (asset_type, asset_key) 的旧记录(避免重复)
Asset.objects.filter(project=project, asset_type=asset_type, asset_key=asset_key).delete()
# 复制 library 文件到 projects/<pid>/ 下,避免污染原文件
import shutil
from time import time
project_media_dir = os.path.join('projects', str(project.id), asset_type)
full_dir = os.path.join(settings.MEDIA_ROOT, project_media_dir)
os.makedirs(full_dir, exist_ok=True)
src_path = os.path.join(settings.MEDIA_ROOT, lib.file_path)
ts = int(time() * 1000)
new_file_name = f'{asset_key}_{ts}_{lib.file_name}'
dst_rel = os.path.join(project_media_dir, new_file_name)
dst_abs = os.path.join(settings.MEDIA_ROOT, dst_rel)
shutil.copy2(src_path, dst_abs)
# 读 svg 尺寸
width = height = None
try:
from PIL import Image
with Image.open(dst_abs) as im:
width, height = im.size
except Exception:
pass
asset = Asset.objects.create(
project=project,
asset_type=asset_type,
asset_key=asset_key,
file_path=dst_rel,
file_name=new_file_name,
width=width,
height=height,
)
return Response({
'ok': True,
'asset_id': asset.id,
'card_key': card_key,
'asset_type': asset_type,
'file_url': f'{settings.MEDIA_URL}{dst_rel}',
}, status=status.HTTP_201_CREATED)