重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器
This commit is contained in:
0
backend/apps/projects/management/__init__.py
Normal file
0
backend/apps/projects/management/__init__.py
Normal file
94
backend/apps/projects/management/commands/init_system.py
Normal file
94
backend/apps/projects/management/commands/init_system.py
Normal 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}'))
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user