feat: 模板预设机制 - 4 套模板各绑一组 JQK/Joker + 背景
经典 → 古典宫廷(王子/皇后/国王/小丑) 现代 → 现代人物(小孩/女青年/男青年/小丑鱼) 卡通 → 现代人物 + 暖色调 + 圆边框 复古 → 简笔符号 + 深色边框 + 米色背景 后端: - CardTemplate 新增 theme_id(绑预设主题)+ design_override(背景/边框/字体等覆盖) - 新增 apply_template_to_project():把 LibraryAsset 复制到项目素材 + 写 design - 创建项目时支持传 template_id,自动套用整套预设 - 模板列表 API 附加 library 预览(4 张图缩略) 前端 Home.vue: - 4 套模板卡片每张带 4 张缩略图(来自 library 预览) - 点模板一键创建项目 + 跳转到编辑器 - '新建空白项目' 保留为独立按钮 init_system 同步:4 套模板配置 + 应用到示例项目
This commit is contained in:
@@ -16,47 +16,79 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.SUCCESS('Initialization complete!'))
|
||||
|
||||
def create_templates(self):
|
||||
"""创建示例模板"""
|
||||
"""创建示例模板:每个模板绑定到一个预设主题"""
|
||||
templates = [
|
||||
{
|
||||
'id': 'classic',
|
||||
'name': '经典风格',
|
||||
'description': '标准扑克牌设计,传统花色和字体',
|
||||
'description': '标准扑克牌设计:王冠/权杖/小丑,黑色红色彩色花色',
|
||||
'color_spade': '#000000',
|
||||
'color_heart': '#E53935',
|
||||
'color_club': '#000000',
|
||||
'color_diamond': '#E53935',
|
||||
'color_background': '#FFFFFF',
|
||||
'theme_id': 'classical',
|
||||
'design_override': {
|
||||
'border_color': '#333333',
|
||||
'border_width': 2,
|
||||
'pip_size_ratio': 0.16,
|
||||
'corner_size_ratio': 0.13,
|
||||
'font_family': 'Times New Roman',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'modern',
|
||||
'name': '现代简约',
|
||||
'description': '扁平化设计,简洁线条',
|
||||
'description': '现代人物主题:小孩/女青年/男青年/小丑鱼,浅色背景',
|
||||
'color_spade': '#333333',
|
||||
'color_heart': '#E53935',
|
||||
'color_club': '#333333',
|
||||
'color_diamond': '#E53935',
|
||||
'color_background': '#FAFAFA',
|
||||
'theme_id': 'modern',
|
||||
'design_override': {
|
||||
'border_color': '#888888',
|
||||
'border_width': 1,
|
||||
'pip_size_ratio': 0.15,
|
||||
'corner_size_ratio': 0.12,
|
||||
'font_family': 'Arial',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'cartoon',
|
||||
'name': '卡通风格',
|
||||
'description': 'Q版可爱人像,圆润花色图案',
|
||||
'description': 'Q版可爱人像,圆润花色图案,暖黄背景',
|
||||
'color_spade': '#4A4A4A',
|
||||
'color_heart': '#FF6B9D',
|
||||
'color_club': '#4A4A4A',
|
||||
'color_diamond': '#FF6B9D',
|
||||
'color_background': '#FFF9E6',
|
||||
'theme_id': 'modern',
|
||||
'design_override': {
|
||||
'border_color': '#FF8E72',
|
||||
'border_width': 3,
|
||||
'pip_size_ratio': 0.17,
|
||||
'corner_size_ratio': 0.14,
|
||||
'font_family': 'Comic Sans MS',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'vintage',
|
||||
'name': '复古风格',
|
||||
'description': '复古色调和纹理,装饰性边框',
|
||||
'description': '复古色调和纹理,深色边框,米色背景',
|
||||
'color_spade': '#2C1810',
|
||||
'color_heart': '#8B4513',
|
||||
'color_club': '#2C1810',
|
||||
'color_diamond': '#8B4513',
|
||||
'color_background': '#F5DEB3',
|
||||
'theme_id': 'minimal',
|
||||
'design_override': {
|
||||
'border_color': '#5D3A1A',
|
||||
'border_width': 4,
|
||||
'pip_size_ratio': 0.15,
|
||||
'corner_size_ratio': 0.13,
|
||||
'font_family': 'Georgia',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -71,24 +103,32 @@ class Command(BaseCommand):
|
||||
'color_club': td['color_club'],
|
||||
'color_diamond': td['color_diamond'],
|
||||
'color_background': td['color_background'],
|
||||
'theme_id': td['theme_id'],
|
||||
'design_override': td['design_override'],
|
||||
'default_assets': td,
|
||||
},
|
||||
)
|
||||
verb = 'created' if created else 'updated'
|
||||
self.stdout.write(f' template {template.id} {verb}')
|
||||
self.stdout.write(f' template {template.id} {verb} (theme={td["theme_id"]})')
|
||||
|
||||
def create_sample_project(self):
|
||||
"""创建示例项目:完整可玩的 54 张牌"""
|
||||
project, created = Project.objects.update_or_create(
|
||||
"""创建示例项目:完整可玩的 54 张牌(应用经典模板)"""
|
||||
from apps.templates.template_apply import apply_template_to_project
|
||||
|
||||
# 删除旧示例项目
|
||||
Project.objects.filter(name="示例项目").delete()
|
||||
|
||||
project = Project.objects.create(
|
||||
name="示例项目",
|
||||
defaults=dict(
|
||||
template_id='classic',
|
||||
card_width=750,
|
||||
card_height=1050,
|
||||
export_resolution='standard',
|
||||
export_include_back=True,
|
||||
),
|
||||
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}')
|
||||
# 应用经典模板(自动写入 design + 复制 4 套古典素材到 asset)
|
||||
tpl = CardTemplate.objects.get(pk='classic')
|
||||
result = apply_template_to_project(project, tpl)
|
||||
self.stdout.write(f' applied template: {result["applied"]} assets')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'示例项目 ID: {project.id}'))
|
||||
|
||||
@@ -26,10 +26,27 @@ def project_list(request):
|
||||
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()
|
||||
# 抽出 template_id(不写进 Project 字段)
|
||||
template_id = data.pop('template_id', None)
|
||||
serializer = ProjectSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
project = serializer.save()
|
||||
# 如果传了 template_id,自动套用模板预设
|
||||
template_apply_result = None
|
||||
if template_id:
|
||||
from apps.templates.models import CardTemplate
|
||||
from apps.templates.template_apply import apply_template_to_project
|
||||
try:
|
||||
tpl = CardTemplate.objects.get(pk=template_id)
|
||||
template_apply_result = apply_template_to_project(project, tpl)
|
||||
except CardTemplate.DoesNotExist:
|
||||
template_apply_result = {'error': f'template {template_id} not found'}
|
||||
# 重新读一次(apply_template 已修改了 design 和 assets)
|
||||
project.refresh_from_db()
|
||||
return Response({
|
||||
**ProjectSerializer(project).data,
|
||||
'template_apply': template_apply_result,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-02 06:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('templates', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cardtemplate',
|
||||
name='design_override',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cardtemplate',
|
||||
name='theme_id',
|
||||
field=models.CharField(blank=True, default='classical', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.db import models
|
||||
import uuid
|
||||
|
||||
|
||||
class CardTemplate(models.Model):
|
||||
"""扑克牌模板模型"""
|
||||
"""扑克牌模板模型(经典/现代/卡通/复古 4 套预设)"""
|
||||
id = models.CharField(max_length=50, primary_key=True) # 'classic', 'modern', etc.
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField()
|
||||
@@ -15,7 +16,15 @@ class CardTemplate(models.Model):
|
||||
color_diamond = models.CharField(max_length=20, default='#FF0000')
|
||||
color_background = models.CharField(max_length=20, default='#FFFFFF')
|
||||
|
||||
# 默认素材路径(JSON)
|
||||
# 模板绑定的预设主题(指向 LibraryAsset.theme_id)
|
||||
# 例如 'classical' 模板 → 使用 LibraryAsset.theme_id='classical' 的所有素材
|
||||
theme_id = models.CharField(max_length=50, blank=True, default='classical')
|
||||
|
||||
# 模板级 design 覆盖:background_color / border_color / pip_size_ratio 等
|
||||
# 不填则走全局默认
|
||||
design_override = models.JSONField(default=dict)
|
||||
|
||||
# 默认素材路径(JSON,保留字段兼容老逻辑)
|
||||
default_assets = models.JSONField(default=dict)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
110
backend/apps/templates/template_apply.py
Normal file
110
backend/apps/templates/template_apply.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
模板应用工具:把 CardTemplate 的预设(theme + background + design)应用到 Project。
|
||||
- 复制 LibraryAsset 中的素材到 projects/<pid>/<asset_type>/<asset_key>_xxx.png
|
||||
- 在 Project 的 design 中写 background_color / border_color / suit_colors 等
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from time import time
|
||||
from django.conf import settings
|
||||
from apps.projects.models import Project, Asset, LibraryAsset
|
||||
|
||||
|
||||
def _copy_lib_to_project(project, lib, asset_type, asset_key):
|
||||
"""把 LibraryAsset 的文件复制到 projects/<pid>/<asset_type>/ 下,并在 Project.assets 建记录"""
|
||||
project_media_dir = f'projects/{project.id}/{asset_type}'
|
||||
full_dir = os.path.join(settings.MEDIA_ROOT, project_media_dir)
|
||||
os.makedirs(full_dir, exist_ok=True)
|
||||
|
||||
src = os.path.join(settings.MEDIA_ROOT, lib.file_path)
|
||||
if not os.path.exists(src):
|
||||
return None
|
||||
|
||||
ts = int(time() * 1000)
|
||||
fn = f'{asset_key}_{ts}_{lib.file_name}'
|
||||
dst_rel = f'{project_media_dir}/{fn}'
|
||||
shutil.copy2(src, os.path.join(settings.MEDIA_ROOT, dst_rel))
|
||||
|
||||
# 读图尺寸
|
||||
width = height = None
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(os.path.join(settings.MEDIA_ROOT, dst_rel)) as im:
|
||||
width, height = im.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 删除同 (asset_type, asset_key) 的旧记录
|
||||
Asset.objects.filter(project=project, asset_type=asset_type, asset_key=asset_key).delete()
|
||||
return Asset.objects.create(
|
||||
project=project,
|
||||
asset_type=asset_type,
|
||||
asset_key=asset_key,
|
||||
file_path=dst_rel,
|
||||
file_name=fn,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
|
||||
def apply_template_to_project(project, template):
|
||||
"""根据 CardTemplate 的预设填充项目:写 design + 复制 JQK/Joker 素材
|
||||
|
||||
默认行为:
|
||||
- 整副牌 background_color / border_color 等设计项按模板的 design_override 写入
|
||||
- 模板绑定的 theme_id 对应的所有 LibraryAsset 复制成 JQK/Joker 资产
|
||||
- 应用到所有 4 个花色 + 大小王
|
||||
"""
|
||||
# 1. 应用 design 覆盖
|
||||
base_design = dict(project.design or {})
|
||||
override = template.design_override or {}
|
||||
base_design.update(override)
|
||||
# 同步花色颜色
|
||||
if 'suit_symbols' not in base_design:
|
||||
base_design['suit_symbols'] = {}
|
||||
base_design['suit_symbols']['spade'] = {'type': 'text', 'value': '♠', 'color': template.color_spade, 'asset_id': None}
|
||||
base_design['suit_symbols']['heart'] = {'type': 'text', 'value': '♥', 'color': template.color_heart, 'asset_id': None}
|
||||
base_design['suit_symbols']['club'] = {'type': 'text', 'value': '♣', 'color': template.color_club, 'asset_id': None}
|
||||
base_design['suit_symbols']['diamond'] = {'type': 'text', 'value': '♦', 'color': template.color_diamond, 'asset_id': None}
|
||||
# 背景色
|
||||
base_design['background_color'] = template.color_background
|
||||
project.design = base_design
|
||||
project.save()
|
||||
|
||||
# 2. 复制主题素材(如果绑定了 theme_id)
|
||||
theme_id = template.theme_id
|
||||
if not theme_id:
|
||||
return {'applied': 0, 'theme_id': None}
|
||||
|
||||
libs = LibraryAsset.objects.filter(theme_id=theme_id)
|
||||
if not libs.exists():
|
||||
return {'applied': 0, 'theme_id': theme_id, 'warning': f'no library assets for theme {theme_id}'}
|
||||
|
||||
applied = []
|
||||
for lib in libs:
|
||||
# asset_type: J/Q/K -> face_card;joker -> joker
|
||||
if lib.role == 'joker':
|
||||
asset_type = 'joker'
|
||||
# 默认应用到 joker-big
|
||||
asset_key = 'joker-big'
|
||||
# 同主题如果有 small 的角色素材就分别处理
|
||||
if lib.asset_id == 'small':
|
||||
asset_key = 'joker-small'
|
||||
else:
|
||||
asset_type = 'face_card'
|
||||
# 4 个花色都复制
|
||||
for suit in ('spade', 'heart', 'club', 'diamond'):
|
||||
asset_key = f'{suit}-{lib.role}'
|
||||
a = _copy_lib_to_project(project, lib, asset_type, asset_key)
|
||||
if a:
|
||||
applied.append(a.id)
|
||||
continue
|
||||
a = _copy_lib_to_project(project, lib, asset_type, asset_key)
|
||||
if a:
|
||||
applied.append(a.id)
|
||||
|
||||
return {
|
||||
'applied': len(applied),
|
||||
'theme_id': theme_id,
|
||||
'asset_ids': applied,
|
||||
}
|
||||
@@ -2,27 +2,49 @@ from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from .models import CardTemplate
|
||||
from apps.projects.models import LibraryAsset
|
||||
|
||||
|
||||
def _template_to_dict(t):
|
||||
return {
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'description': t.description,
|
||||
'preview_image': t.preview_image.url if t.preview_image else None,
|
||||
'theme_id': t.theme_id,
|
||||
'colors': {
|
||||
'spade': t.color_spade,
|
||||
'heart': t.color_heart,
|
||||
'club': t.color_club,
|
||||
'diamond': t.color_diamond,
|
||||
'background': t.color_background,
|
||||
},
|
||||
'design_override': t.design_override or {},
|
||||
}
|
||||
|
||||
|
||||
def _template_with_preview(t):
|
||||
"""附加 library 中 theme_id 对应的素材预览(前端可以画一组小图)"""
|
||||
out = _template_to_dict(t)
|
||||
libs = LibraryAsset.objects.filter(theme_id=t.theme_id)
|
||||
out['library'] = [
|
||||
{
|
||||
'id': lib.id,
|
||||
'role': lib.role,
|
||||
'role_name': lib.role_name,
|
||||
'label': lib.label,
|
||||
'file_url': f'/media/{lib.file_path}',
|
||||
}
|
||||
for lib in libs
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def template_list(request):
|
||||
"""获取所有模板列表"""
|
||||
"""获取所有模板列表(含每个模板的 theme 预览)"""
|
||||
templates = CardTemplate.objects.all()
|
||||
data = []
|
||||
for template in templates:
|
||||
data.append({
|
||||
'id': template.id,
|
||||
'name': template.name,
|
||||
'description': template.description,
|
||||
'preview_image': template.preview_image.url if template.preview_image else None,
|
||||
'colors': {
|
||||
'spade': template.color_spade,
|
||||
'heart': template.color_heart,
|
||||
'club': template.color_club,
|
||||
'diamond': template.color_diamond,
|
||||
'background': template.color_background,
|
||||
},
|
||||
})
|
||||
data = [_template_with_preview(t) for t in templates]
|
||||
return Response(data)
|
||||
|
||||
|
||||
@@ -33,26 +55,4 @@ def template_detail(request, pk):
|
||||
template = CardTemplate.objects.get(pk=pk)
|
||||
except CardTemplate.DoesNotExist:
|
||||
return Response({'error': 'Template not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
data = {
|
||||
'id': template.id,
|
||||
'name': template.name,
|
||||
'description': template.description,
|
||||
'preview_image': template.preview_image.url if template.preview_image else None,
|
||||
'colors': {
|
||||
'spade': template.color_spade,
|
||||
'heart': template.color_heart,
|
||||
'club': template.color_club,
|
||||
'diamond': template.color_diamond,
|
||||
'background': template.color_background,
|
||||
},
|
||||
'default_assets': template.default_assets,
|
||||
'suit_symbols': {
|
||||
ss.suit_name: {
|
||||
'svg_path': ss.svg_path,
|
||||
'color': ss.color,
|
||||
}
|
||||
for ss in template.suit_symbols.all()
|
||||
}
|
||||
}
|
||||
return Response(data)
|
||||
return Response(_template_with_preview(template))
|
||||
|
||||
Reference in New Issue
Block a user