diff --git a/backend/apps/projects/management/commands/init_system.py b/backend/apps/projects/management/commands/init_system.py index 70a6572..3d89679 100644 --- a/backend/apps/projects/management/commands/init_system.py +++ b/backend/apps/projects/management/commands/init_system.py @@ -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}')) diff --git a/backend/apps/projects/views.py b/backend/apps/projects/views.py index 73f5cb3..006e811 100644 --- a/backend/apps/projects/views.py +++ b/backend/apps/projects/views.py @@ -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) diff --git a/backend/apps/templates/migrations/0002_cardtemplate_design_override_cardtemplate_theme_id.py b/backend/apps/templates/migrations/0002_cardtemplate_design_override_cardtemplate_theme_id.py new file mode 100644 index 0000000..b6e411b --- /dev/null +++ b/backend/apps/templates/migrations/0002_cardtemplate_design_override_cardtemplate_theme_id.py @@ -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), + ), + ] diff --git a/backend/apps/templates/models.py b/backend/apps/templates/models.py index 1a4842d..3c73c9b 100644 --- a/backend/apps/templates/models.py +++ b/backend/apps/templates/models.py @@ -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) diff --git a/backend/apps/templates/template_apply.py b/backend/apps/templates/template_apply.py new file mode 100644 index 0000000..ba74487 --- /dev/null +++ b/backend/apps/templates/template_apply.py @@ -0,0 +1,110 @@ +""" +模板应用工具:把 CardTemplate 的预设(theme + background + design)应用到 Project。 +- 复制 LibraryAsset 中的素材到 projects///_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/// 下,并在 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, + } diff --git a/backend/apps/templates/views.py b/backend/apps/templates/views.py index 8622fcf..a19d6f3 100644 --- a/backend/apps/templates/views.py +++ b/backend/apps/templates/views.py @@ -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)) diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 45dffaa..fa94d01 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -8,15 +8,22 @@

选择模板系列开始设计

-
-
点击下方任一模板,自动创建项目并预填 JQK 人物 + 大小王 + 背景色

+
+
-
{{ t.icon }}
+ @click="createFromTemplate(t)"> +
{{ suitIconFor(t.id) }}

{{ t.name }}

-

{{ t.desc }}

+

{{ t.description }}

+
+ +
+
{{ t.library?.length || 0 }} 张预设素材
+
加载模板中…
+
未获取到模板
@@ -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; }