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:
Developer
2026-06-02 15:08:37 +08:00
parent 5ca000b8ab
commit 7417a4a893
7 changed files with 309 additions and 78 deletions

View File

@@ -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,
),
)
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}'))

View File

@@ -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)

View File

@@ -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),
),
]

View File

@@ -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)

View 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_cardjoker -> 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,
}

View File

@@ -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))

View File

@@ -8,15 +8,22 @@
<main>
<section>
<h2>选择模板系列开始设计</h2>
<div class="template-grid">
<div v-for="t in templateList" :key="t.id"
<p class="hint">点击下方任一模板自动创建项目并预填 JQK 人物 + 大小王 + 背景色</p>
<div class="template-grid" v-if="!templateLoading && templates.length">
<div v-for="t in templates" :key="t.id"
class="template-card"
@click="createFromTemplate(t.id)">
<div class="icon">{{ t.icon }}</div>
@click="createFromTemplate(t)">
<div class="icon">{{ suitIconFor(t.id) }}</div>
<h3>{{ t.name }}</h3>
<p>{{ t.desc }}</p>
<p class="desc">{{ t.description }}</p>
<div class="preview" v-if="t.library && t.library.length">
<img v-for="(lib, i) in t.library.slice(0, 4)" :key="i" :src="lib.file_url" :alt="lib.role_name" />
</div>
<div class="cardinality">{{ t.library?.length || 0 }} 张预设素材</div>
</div>
</div>
<div v-else-if="templateLoading" class="muted">加载模板中</div>
<div v-else class="muted">未获取到模板</div>
</section>
<section v-if="hasProjects" class="project-list">
@@ -45,22 +52,44 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import axios from 'axios'
import { useProjectStore } from '@/stores/projectStore.js'
const router = useRouter()
const store = useProjectStore()
const { projects, loading, error } = storeToRefs(store)
const templateList = [
{ id: 'classic', name: '经典风格', desc: '标准扑克牌设计,传统花色和字体', icon: '♠' },
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像圆润花色图案', icon: '★' },
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' },
]
const templates = ref([])
const templateLoading = ref(false)
const TEMPLATE_ICONS = {
classic: '♛',
modern: '◆',
cartoon: '★',
vintage: '♟',
}
function suitIconFor(tid) {
return TEMPLATE_ICONS[tid] || '♠'
}
const hasProjects = computed(() => projects.value.length > 0)
onMounted(() => store.fetchProjects())
onMounted(async () => {
store.fetchProjects()
loadTemplates()
})
async function loadTemplates() {
templateLoading.value = true
try {
const r = await axios.get('/api/templates/')
templates.value = r.data || []
} catch (e) {
console.error('load templates failed', e)
} finally {
templateLoading.value = false
}
}
async function doCreate() {
try {
@@ -71,10 +100,9 @@ async function doCreate() {
}
}
async function createFromTemplate(tid) {
async function createFromTemplate(t) {
try {
const nm = templateList.find(t => t.id === tid)?.name || tid
const p = await store.createProject(nm + ' - ' + new Date().toLocaleDateString(), tid)
const p = await store.createProject(t.name + ' - ' + new Date().toLocaleDateString(), t.id)
router.push('/editor/' + p.id)
} catch (e) {
alert('创建失败: ' + e.message)
@@ -105,13 +133,17 @@ function formatDate(d) {
.topbar h1 { margin: 0; font-size: 24px; color: #e94560; }
main { max-width: 1200px; margin: 0 auto; padding: 30px 20px; }
section { margin-bottom: 36px; }
h2 { margin: 0 0 18px 0; font-size: 18px; color: #ccc; }
h2 { margin: 0 0 8px 0; font-size: 18px; color: #ccc; }
.hint { margin: 0 0 18px 0; font-size: 12px; color: #888; }
.template-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
.template-card { background: #0f3460; border-radius: 12px; padding: 22px; cursor: pointer; transition: all 0.2s; text-align: center; }
.template-card:hover { background: #16213e; transform: translateY(-2px); }
.template-card:hover { background: #16213e; transform: translateY(-2px); box-shadow: 0 6px 20px rgba(233, 69, 96, 0.15); }
.template-card .icon { font-size: 44px; margin-bottom: 10px; color: #e94560; }
.template-card h3 { margin: 0 0 6px 0; font-size: 16px; }
.template-card p { margin: 0; font-size: 12px; color: #aaa; line-height: 1.5; }
.template-card .desc { margin: 0 0 12px 0; font-size: 12px; color: #aaa; line-height: 1.5; min-height: 36px; }
.template-card .preview { display: flex; gap: 4px; justify-content: center; margin-bottom: 10px; background: #fff; border-radius: 4px; padding: 4px; }
.template-card .preview img { width: 36px; height: 48px; object-fit: contain; }
.template-card .cardinality { font-size: 11px; color: #e94560; font-weight: bold; }
.project-list { background: #0f3460; border-radius: 12px; padding: 22px; }
.project-row { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 8px; }
.project-row .meta { font-size: 12px; color: #888; margin-top: 4px; }