重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器

This commit is contained in:
Developer
2026-06-01 17:11:06 +08:00
parent bde508dcfe
commit 2a36aa593c
20 changed files with 2326 additions and 853 deletions

View File

@@ -0,0 +1,94 @@
import os
from django.core.management.base import BaseCommand
from apps.projects.models import Project, Asset
from apps.templates.models import CardTemplate
class Command(BaseCommand):
help = 'Initialize cards design system with sample data'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Starting initialization...'))
self.create_templates()
self.create_sample_project()
self.stdout.write(self.style.SUCCESS('Initialization complete!'))
def create_templates(self):
"""创建示例模板"""
templates = [
{
'id': 'classic',
'name': '经典风格',
'description': '标准扑克牌设计,传统花色和字体',
'color_spade': '#000000',
'color_heart': '#E53935',
'color_club': '#000000',
'color_diamond': '#E53935',
'color_background': '#FFFFFF',
},
{
'id': 'modern',
'name': '现代简约',
'description': '扁平化设计,简洁线条',
'color_spade': '#333333',
'color_heart': '#E53935',
'color_club': '#333333',
'color_diamond': '#E53935',
'color_background': '#FAFAFA',
},
{
'id': 'cartoon',
'name': '卡通风格',
'description': 'Q版可爱人像圆润花色图案',
'color_spade': '#4A4A4A',
'color_heart': '#FF6B9D',
'color_club': '#4A4A4A',
'color_diamond': '#FF6B9D',
'color_background': '#FFF9E6',
},
{
'id': 'vintage',
'name': '复古风格',
'description': '复古色调和纹理,装饰性边框',
'color_spade': '#2C1810',
'color_heart': '#8B4513',
'color_club': '#2C1810',
'color_diamond': '#8B4513',
'color_background': '#F5DEB3',
},
]
for td in templates:
template, created = CardTemplate.objects.update_or_create(
id=td['id'],
defaults={
'name': td['name'],
'description': td['description'],
'color_spade': td['color_spade'],
'color_heart': td['color_heart'],
'color_club': td['color_club'],
'color_diamond': td['color_diamond'],
'color_background': td['color_background'],
'default_assets': td,
},
)
verb = 'created' if created else 'updated'
self.stdout.write(f' template {template.id} {verb}')
def create_sample_project(self):
"""创建示例项目:完整可玩的 54 张牌"""
project, created = Project.objects.update_or_create(
name="示例项目",
defaults=dict(
template_id='classic',
card_width=750,
card_height=1050,
export_resolution='standard',
export_include_back=True,
),
)
verb = 'created' if created else 'updated'
self.stdout.write(f' project "{project.name}" {verb}')
self.stdout.write(self.style.SUCCESS(f'示例项目 ID: {project.id}'))

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2.14 on 2026-06-01 05:55
import apps.projects.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='project',
name='card_overrides',
field=models.JSONField(default=apps.projects.models.default_card_overrides),
),
migrations.AddField(
model_name='project',
name='design',
field=models.JSONField(default=apps.projects.models.default_design),
),
migrations.AddField(
model_name='project',
name='face_orientations',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='project',
name='number_layout',
field=models.JSONField(default=dict),
),
migrations.AlterField(
model_name='asset',
name='file_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
model_name='asset',
name='file_path',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='asset',
name='height',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='asset',
name='width',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -2,6 +2,40 @@ from django.db import models
import uuid
def default_design():
"""默认设计配置(整副牌共享)"""
return {
# 全局背景色(整副牌默认用这个,个别牌可覆盖)
'background_color': '#FFFFFF',
'background_image': None, # 整副牌背景图,相对 media 的路径
# 整副牌边框
'border_color': '#333333',
'border_width': 2,
# 4 个花色符号:可以上传图片,也可保持 None用字体符号
'suit_symbols': {
'spade': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#000000'},
'heart': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#E53935'},
'club': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#000000'},
'diamond': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#E53935'},
},
# 数字牌角标和中心花色符号的大小(占牌面宽度比例)
'corner_size_ratio': 0.13,
'pip_size_ratio': 0.16,
# 字体
'font_family': 'Times New Roman',
'font_color': '#000000', # 角标数字颜色
# 角标布局微调(相对位置 0~1
'corner_offset': {'x': 0, 'y': 0},
}
def default_card_overrides():
"""每张牌可独立覆盖的项目级设置key=card_key, value 覆盖项)"""
return {
# 例如 'joker-big': { 'background_color': '#1B5E20' }
}
class Project(models.Model):
"""项目配置模型"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -9,6 +43,15 @@ class Project(models.Model):
template_id = models.CharField(max_length=50, default='classic')
card_width = models.IntegerField(default=750)
card_height = models.IntegerField(default=1050)
# 项目级设计配置
design = models.JSONField(default=default_design)
# 每张牌对项目级配置的覆盖
card_overrides = models.JSONField(default=default_card_overrides)
# 数字牌花色位置微调(相对 0~1
# { '1': [{'dx':0,'dy':0,'scale':1}, ...], '2': [...], ... }
number_layout = models.JSONField(default=dict)
# JQK 人物图的水平翻转(每张牌独立)
face_orientations = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -26,12 +69,12 @@ class Project(models.Model):
class Asset(models.Model):
"""项目素材模型"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets')
asset_type = models.CharField(max_length=20) # 'suit_symbol', 'face_card', 'joker', 'back', 'border'
asset_type = models.CharField(max_length=20) # 'suit_symbol', 'face_card', 'joker', 'back', 'border', 'background'
asset_key = models.CharField(max_length=50) # 如 'spade', 'heart-J', 'big_joker'
file_path = models.CharField(max_length=255) # 相对于media目录
file_name = models.CharField(max_length=100)
width = models.IntegerField(null=True)
height = models.IntegerField(null=True)
file_path = models.CharField(max_length=255, blank=True) # 相对于media目录
file_name = models.CharField(max_length=100, blank=True)
width = models.IntegerField(null=True, blank=True)
height = models.IntegerField(null=True, blank=True)
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
@@ -42,12 +85,12 @@ class Asset(models.Model):
class CardLayer(models.Model):
"""牌面图层配置模型"""
"""牌面图层配置模型(图层顺序、可见性等)"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='layers')
card_type = models.CharField(max_length=20) # 'number', 'face', 'joker'
card_key = models.CharField(max_length=30) # 'spade-A', 'heart-K', 'big_joker'
card_type = models.CharField(max_length=20) # 'number', 'face', 'joker', 'back'
card_key = models.CharField(max_length=30) # 'spade-A', 'heart-K', 'joker-big'
layer_name = models.CharField(max_length=50)
layer_type = models.CharField(max_length=20) # 'background', 'border', 'image', 'text'
layer_type = models.CharField(max_length=20) # 'background', 'border', 'pattern', 'image', 'text', 'symbol'
visible = models.BooleanField(default=True)
locked = models.BooleanField(default=False)
opacity = models.FloatField(default=1.0)

View File

@@ -6,7 +6,13 @@ from .models import Project, Asset, CardLayer
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = '__all__'
fields = [
'id', 'name', 'template_id',
'card_width', 'card_height',
'design', 'card_overrides', 'number_layout', 'face_orientations',
'export_resolution', 'export_include_back',
'created_at', 'updated_at',
]
class AssetSerializer(serializers.ModelSerializer):
@@ -14,7 +20,8 @@ class AssetSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = '__all__'
fields = ['id', 'asset_type', 'asset_key', 'file_path', 'file_name',
'file_url', 'width', 'height', 'uploaded_at']
def get_file_url(self, obj):
if obj.file_path:
@@ -37,4 +44,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = '__all__'
fields = [
'id', 'name', 'template_id',
'card_width', 'card_height',
'design', 'card_overrides', 'number_layout', 'face_orientations',
'export_resolution', 'export_include_back',
'assets', 'layers',
'created_at', 'updated_at',
]

View File

@@ -1,9 +1,13 @@
from django.urls import path
from .views import project_list, project_detail, asset_list, asset_detail
from .views import (
project_list, project_detail, project_save_design,
asset_list, asset_detail,
)
urlpatterns = [
path('', project_list, name='project-list'),
path('<str:pk>/', project_detail, name='project-detail'),
path('<str:pk>/design/', project_save_design, name='project-save-design'),
path('<str:project_pk>/assets/', asset_list, name='asset-list'),
path('<str:project_pk>/assets/<str:asset_pk>/', asset_detail, name='asset-detail'),
]

View File

@@ -18,7 +18,15 @@ def project_list(request):
return Response(serializer.data)
elif request.method == 'POST':
serializer = ProjectSerializer(data=request.data)
# 自动补默认 design/card_overrides/number_layout
data = dict(request.data or {})
if 'design' not in data:
data['design'] = Project._meta.get_field('design').default()
if 'card_overrides' not in data:
data['card_overrides'] = Project._meta.get_field('card_overrides').default()
if 'number_layout' not in data:
data['number_layout'] = Project._meta.get_field('number_layout').default()
serializer = ProjectSerializer(data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -38,7 +46,7 @@ def project_detail(request, pk):
return Response(serializer.data)
elif request.method == 'PUT':
serializer = ProjectSerializer(project, data=request.data)
serializer = ProjectSerializer(project, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
@@ -49,6 +57,27 @@ def project_detail(request, pk):
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
def project_save_design(request, pk):
"""整体保存项目设计design / card_overrides / number_layout"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
return Response({'error': 'Project not found'}, status=status.HTTP_404_NOT_FOUND)
for field in ('design', 'card_overrides', 'number_layout', 'face_orientations'):
if field in request.data:
setattr(project, field, request.data[field])
project.save()
return Response({
'ok': True,
'design': project.design,
'card_overrides': project.card_overrides,
'number_layout': project.number_layout,
'face_orientations': project.face_orientations,
})
@api_view(['GET', 'POST'])
def asset_list(request, project_pk):
"""获取项目素材列表或上传新素材"""
@@ -59,7 +88,7 @@ def asset_list(request, project_pk):
if request.method == 'GET':
assets = project.assets.all()
serializer = AssetSerializer(assets, many=True)
serializer = AssetSerializer(assets, many=True, context={'request': request})
return Response(serializer.data)
elif request.method == 'POST':
@@ -75,19 +104,21 @@ def asset_list(request, project_pk):
full_dir = os.path.join(settings.MEDIA_ROOT, project_media_dir)
os.makedirs(full_dir, exist_ok=True)
# 保存文件
file_name = f"{asset_key}_{file.name}"
# 避免重名覆盖:补上时间戳
from time import time
ts = int(time() * 1000)
file_name = f"{asset_key}_{ts}_{file.name}"
file_path = os.path.join(project_media_dir, file_name)
saved_path = default_storage.save(file_path, file)
# 获取图片尺寸
width, height = None, None
try:
img = Image.open(file)
width, height = img.size
except:
width, height = None, None
except Exception:
pass
# 创建Asset记录
asset = Asset.objects.create(
project=project,
asset_type=asset_type,
@@ -95,10 +126,10 @@ def asset_list(request, project_pk):
file_path=saved_path,
file_name=file_name,
width=width,
height=height
height=height,
)
serializer = AssetSerializer(asset)
serializer = AssetSerializer(asset, context={'request': request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -112,15 +143,16 @@ def asset_detail(request, project_pk, asset_pk):
return Response({'error': 'Asset not found'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
serializer = AssetSerializer(asset)
serializer = AssetSerializer(asset, context={'request': request})
return Response(serializer.data)
elif request.method == 'DELETE':
# 删除文件
if asset.file_path:
file_full_path = os.path.join(settings.MEDIA_ROOT, asset.file_path)
if os.path.exists(file_full_path):
os.remove(file_full_path)
try:
os.remove(file_full_path)
except OSError:
pass
asset.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(status=status.HTTP_204_NO_CONTENT)