diff --git a/backend/apps/management/__init__.py b/backend/apps/management/__init__.py
new file mode 100644
index 0000000..a693ec4
--- /dev/null
+++ b/backend/apps/management/__init__.py
@@ -0,0 +1 @@
+# management/__init__.py
\ No newline at end of file
diff --git a/backend/apps/management/commands/__init__.py b/backend/apps/management/commands/__init__.py
new file mode 100644
index 0000000..61f42e0
--- /dev/null
+++ b/backend/apps/management/commands/__init__.py
@@ -0,0 +1 @@
+# management/commands/__init__.py
\ No newline at end of file
diff --git a/backend/apps/management/commands/init_system.py b/backend/apps/management/commands/init_system.py
new file mode 100644
index 0000000..c07b8ad
--- /dev/null
+++ b/backend/apps/management/commands/init_system.py
@@ -0,0 +1,145 @@
+import os
+from django.core.management.base import BaseCommand
+from django.core.files.storage import default_storage
+from apps.projects.models import Project, Asset, CardLayer
+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_default_assets()
+ 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': '#FF0000',
+ 'color_club': '#000000',
+ 'color_diamond': '#FF0000',
+ 'color_background': '#FFFFFF',
+ },
+ {
+ 'id': 'modern',
+ 'name': '现代简约',
+ 'description': '扁平化设计,简洁线条',
+ 'color_spade': '#333333',
+ 'color_heart': '#E53935',
+ 'color_club': '#333333',
+ 'color_diamond': '#E53935',
+ 'color_background': '#FAFAFA',
+ }
+ ]
+
+ for template_data in templates:
+ try:
+ template = CardTemplate.objects.get(id=template_data['id'])
+ if not template.default_assets:
+ template.default_assets = template_data
+ template.save()
+ except CardTemplate.DoesNotExist:
+ template = CardTemplate.objects.create(
+ id=template_data['id'],
+ name=template_data['name'],
+ description=template_data['description'],
+ color_spade=template_data['color_spade'],
+ color_heart=template_data['color_heart'],
+ color_club=template_data['color_club'],
+ color_diamond=template_data['color_diamond'],
+ color_background=template_data['color_background'],
+ default_assets=template_data
+ )
+
+ def create_default_assets(self):
+ """创建默认花色素材"""
+ suits = {
+ 'spade': 0xE27B60,
+ 'heart': 0xE27B60,
+ 'club': 0xE27B60,
+ 'diamond': 0xE27B60
+ }
+
+ materials = 'backend/media/assets'
+ os.makedirs(materials, exist_ok=True)
+
+ for suit_name, color_code in suits.items():
+ # 创建简单SVG花色图案
+ svg_path = os.path.join('backend/media/assets', f'{suit_name}.svg')
+
+ storyboardSVG = ''''''
+
+ with open(svg_path, 'w', encoding='utf-8') as f:
+ f.write(storyboardSVG)
+
+ # 创建Asset记录
+ Asset.objects.create(
+ asset_type='suit_symbol',
+ asset_key=suit_name,
+ color=f'#{color_code:06X}'
+ )
+
+ def create_sample_project(self):
+ """创建示例项目"""
+ try:
+ project = Project.objects.get(name="示例项目")
+ self.stdout.write(self.style.WARNING('示例项目已存在,跳过创建'))
+ return
+ except Project.DoesNotExist:
+ project = Project.objects.create(
+ name="示例项目",
+ template_id='classic',
+ card_width=750,
+ card_height=1050,
+ export_resolution='standard',
+ export_include_back=True
+ )
+
+ # 创建示例素材
+ suit_assets = [
+ {'type': 'suit_symbol', 'key': 'spade'},
+ {'type': 'suit_symbol', 'key': 'heart'},
+ {'type': 'suit_symbol', 'key': 'club'},
+ {'type': 'suit_symbol', 'key': 'diamond'},
+ ]
+
+ for asset_data in suit_assets:
+ Asset.objects.create(
+ asset_type=asset_data['type'],
+ asset_key=asset_data['key'],
+ width=60,
+ height=60
+ )
+
+ # 创建JQK示例素材记录(临时)
+ face_cards = [
+ {'type': 'face_card', 'key': 'spade-J'},
+ {'type': 'face_card', 'key': 'spade-Q'},
+ {'type': 'face_card', 'key': 'spade-K'},
+ ]
+
+ for face_card in face_cards:
+ Asset.objects.create(
+ asset_type=face_card['type'],
+ asset_key=face_card['key'],
+ width=300,
+ height=500
+ )
+
+ self.stdout.write(self.style.SUCCESS(f'项目 "{project.name}" 已创建'))
diff --git a/backend/apps/projects/models.py b/backend/apps/projects/models.py
index d05c56a..5cb0beb 100644
--- a/backend/apps/projects/models.py
+++ b/backend/apps/projects/models.py
@@ -1,6 +1,7 @@
from django.db import models
import uuid
+
class Project(models.Model):
"""项目配置模型"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -24,8 +25,11 @@ 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_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)
uploaded_at = models.DateTimeField(auto_now_add=True)
@@ -33,9 +37,13 @@ class Asset(models.Model):
def __str__(self):
return f"{self.asset_type}:{self.asset_key}"
+ class Meta:
+ ordering = ['-uploaded_at']
+
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'
layer_name = models.CharField(max_length=50)
@@ -47,7 +55,6 @@ class CardLayer(models.Model):
# 图层属性(JSON存储)
properties = models.JSONField(default=dict)
- file_ref = models.ForeignKey(Asset, on_delete=models.SET_NULL, null=True, related_name='layers')
def __str__(self):
return f"{self.card_key}-{self.layer_name}"
diff --git a/backend/apps/projects/serializers.py b/backend/apps/projects/serializers.py
index d4b4847..4e36931 100644
--- a/backend/apps/projects/serializers.py
+++ b/backend/apps/projects/serializers.py
@@ -1,4 +1,5 @@
from rest_framework import serializers
+from django.conf import settings
from .models import Project, Asset, CardLayer
@@ -9,10 +10,20 @@ class ProjectSerializer(serializers.ModelSerializer):
class AssetSerializer(serializers.ModelSerializer):
+ file_url = serializers.SerializerMethodField()
+
class Meta:
model = Asset
fields = '__all__'
+ def get_file_url(self, obj):
+ if obj.file_path:
+ request = self.context.get('request')
+ if request:
+ return request.build_absolute_uri(f'{settings.MEDIA_URL}{obj.file_path}')
+ return f'{settings.MEDIA_URL}{obj.file_path}'
+ return None
+
class CardLayerSerializer(serializers.ModelSerializer):
class Meta:
diff --git a/backend/apps/projects/urls.py b/backend/apps/projects/urls.py
index e0ed3ac..69cde9f 100644
--- a/backend/apps/projects/urls.py
+++ b/backend/apps/projects/urls.py
@@ -1,7 +1,9 @@
from django.urls import path
-from .views import project_list, project_detail
+from .views import project_list, project_detail, asset_list, asset_detail
urlpatterns = [
path('', project_list, name='project-list'),
path('/', project_detail, name='project-detail'),
+ path('/assets/', asset_list, name='asset-list'),
+ path('/assets//', asset_detail, name='asset-detail'),
]
diff --git a/backend/apps/projects/views.py b/backend/apps/projects/views.py
index bdce82d..7c36199 100644
--- a/backend/apps/projects/views.py
+++ b/backend/apps/projects/views.py
@@ -1,8 +1,12 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
-from .models import Project
-from .serializers import ProjectSerializer, ProjectDetailSerializer
+from django.core.files.storage import default_storage
+from django.conf import settings
+from PIL import Image
+import os
+from .models import Project, Asset
+from .serializers import ProjectSerializer, ProjectDetailSerializer, AssetSerializer
@api_view(['GET', 'POST'])
@@ -42,4 +46,81 @@ def project_detail(request, pk):
elif request.method == 'DELETE':
project.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+@api_view(['GET', 'POST'])
+def asset_list(request, project_pk):
+ """获取项目素材列表或上传新素材"""
+ try:
+ project = Project.objects.get(pk=project_pk)
+ except Project.DoesNotExist:
+ return Response({'error': 'Project not found'}, status=status.HTTP_404_NOT_FOUND)
+
+ if request.method == 'GET':
+ assets = project.assets.all()
+ serializer = AssetSerializer(assets, many=True)
+ return Response(serializer.data)
+
+ elif request.method == 'POST':
+ if 'file' not in request.FILES:
+ return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST)
+
+ file = request.FILES['file']
+ asset_type = request.POST.get('asset_type', 'unknown')
+ asset_key = request.POST.get('asset_key', 'unknown')
+
+ # 创建项目素材目录
+ project_media_dir = os.path.join('projects', str(project.id), asset_type)
+ 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}"
+ file_path = os.path.join(project_media_dir, file_name)
+ saved_path = default_storage.save(file_path, file)
+
+ # 获取图片尺寸
+ try:
+ img = Image.open(file)
+ width, height = img.size
+ except:
+ width, height = None, None
+
+ # 创建Asset记录
+ asset = Asset.objects.create(
+ project=project,
+ asset_type=asset_type,
+ asset_key=asset_key,
+ file_path=saved_path,
+ file_name=file_name,
+ width=width,
+ height=height
+ )
+
+ serializer = AssetSerializer(asset)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+
+@api_view(['GET', 'DELETE'])
+def asset_detail(request, project_pk, asset_pk):
+ """获取或删除单个素材"""
+ try:
+ project = Project.objects.get(pk=project_pk)
+ asset = project.assets.get(pk=asset_pk)
+ except (Project.DoesNotExist, Asset.DoesNotExist):
+ return Response({'error': 'Asset not found'}, status=status.HTTP_404_NOT_FOUND)
+
+ if request.method == 'GET':
+ serializer = AssetSerializer(asset)
+ 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)
+
+ asset.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
\ No newline at end of file
diff --git a/frontend/src/api/asset.js b/frontend/src/api/asset.js
new file mode 100644
index 0000000..af2d5ca
--- /dev/null
+++ b/frontend/src/api/asset.js
@@ -0,0 +1,26 @@
+import axios from 'axios'
+
+const API_BASE = '/api'
+
+export async function getAssets(projectId) {
+ const response = await axios.get(`${API_BASE}/projects/${projectId}/assets/`)
+ return response.data
+}
+
+export async function uploadAsset(projectId, file, assetType, assetKey) {
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('asset_type', assetType)
+ formData.append('asset_key', assetKey)
+
+ const response = await axios.post(`${API_BASE}/projects/${projectId}/assets/`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ })
+ return response.data
+}
+
+export async function deleteAsset(projectId, assetId) {
+ await axios.delete(`${API_BASE}/projects/${projectId}/assets/${assetId}/`)
+}
\ No newline at end of file
diff --git a/frontend/src/components/AssetUploadDialog.vue b/frontend/src/components/AssetUploadDialog.vue
new file mode 100644
index 0000000..d0ede74
--- /dev/null
+++ b/frontend/src/components/AssetUploadDialog.vue
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 拖拽文件到此处或点击上传
+
+
+
+ 支持 PNG, JPG, SVG 格式
+
+
+
+
+
+
+
+ 取消
+
+ 上传
+
+
+
+
+
+
+
+
diff --git a/frontend/src/templates/index.js b/frontend/src/templates/index.js
new file mode 100644
index 0000000..928b565
--- /dev/null
+++ b/frontend/src/templates/index.js
@@ -0,0 +1,108 @@
+const templates = [
+ {
+ id: 'classic',
+ name: '经典风格',
+ description: '标准扑克牌设计,传统花色和字体',
+ colors: {
+ spade: '#000000',
+ heart: '#FF0000',
+ club: '#000000',
+ diamond: '#FF0000',
+ background: '#FFFFFF'
+ },
+ defaultAssets: {
+ suitSymbols: {
+ spade: '/assets/default/spade.svg',
+ heart: '/assets/default/heart.svg',
+ club: '/assets/default/club.svg',
+ diamond: '/assets/default/diamond.svg'
+ },
+ numberFont: {
+ family: 'Times New Roman',
+ size: 48,
+ color: '#000000'
+ }
+ },
+ features: ['标准边框', '传统字体', '对称布局']
+ },
+ {
+ id: 'modern',
+ name: '现代简约',
+ description: '扁平化设计,简洁线条',
+ colors: {
+ spade: '#333333',
+ heart: '#E53935',
+ club: '#333333',
+ diamond: '#E53935',
+ background: '#FAFAFA'
+ },
+ defaultAssets: {
+ suitSymbols: {
+ spade: '/assets/modern/spade.svg',
+ heart: '/assets/modern/heart.svg',
+ club: '/assets/modern/club.svg',
+ diamond: '/assets/modern/diamond.svg'
+ },
+ numberFont: {
+ family: 'Arial',
+ size: 42,
+ color: '#333333'
+ }
+ },
+ features: ['无边框', '简约字体', '清爽设计']
+ },
+ {
+ id: 'cartoon',
+ name: '卡通风格',
+ description: 'Q版可爱人像,圆润花色图案',
+ colors: {
+ spade: '#4A4A4A',
+ heart: '#FF6B9D',
+ club: '#4A4A4A',
+ diamond: '#FF6B9D',
+ background: '#FFF9E6'
+ },
+ defaultAssets: {
+ suitSymbols: {
+ spade: '/assets/cartoon/spade.svg',
+ heart: '/assets/cartoon/heart.svg',
+ club: '/assets/cartoon/club.svg',
+ diamond: '/assets/cartoon/diamond.svg'
+ },
+ numberFont: {
+ family: 'Comic Sans MS',
+ size: 40,
+ color: '#4A4A4A'
+ }
+ },
+ features: ['圆润边框', '可爱字体', '彩色设计']
+ },
+ {
+ id: 'vintage',
+ name: '复古风格',
+ description: '复古色调和纹理,装饰性边框',
+ colors: {
+ spade: '#2C1810',
+ heart: '#8B4513',
+ club: '#2C1810',
+ diamond: '#8B4513',
+ background: '#F5DEB3'
+ },
+ defaultAssets: {
+ suitSymbols: {
+ spade: '/assets/vintage/spade.svg',
+ heart: '/assets/vintage/heart.svg',
+ club: '/assets/vintage/club.svg',
+ diamond: '/assets/vintage/diamond.svg'
+ },
+ numberFont: {
+ family: 'Georgia',
+ size: 44,
+ color: '#2C1810'
+ }
+ },
+ features: ['装饰边框', '复古字体', '纹理背景']
+ }
+]
+
+export default templates
\ No newline at end of file
diff --git a/frontend/src/utils/cardLayout.js b/frontend/src/utils/cardLayout.js
new file mode 100644
index 0000000..aa5d817
--- /dev/null
+++ b/frontend/src/utils/cardLayout.js
@@ -0,0 +1,128 @@
+const LAYOUT_POSITIONS = {
+ 1: [
+ { x: 0.5, y: 0.5 }
+ ],
+ 2: [
+ { x: 0.5, y: 0.25 },
+ { x: 0.5, y: 0.75 }
+ ],
+ 3: [
+ { x: 0.5, y: 0.2 },
+ { x: 0.5, y: 0.5 },
+ { x: 0.5, y: 0.8 }
+ ],
+ 4: [
+ { x: 0.3, y: 0.25 },
+ { x: 0.7, y: 0.25 },
+ { x: 0.3, y: 0.75 },
+ { x: 0.7, y: 0.75 }
+ ],
+ 5: [
+ { x: 0.3, y: 0.2 },
+ { x: 0.7, y: 0.2 },
+ { x: 0.5, y: 0.5 },
+ { x: 0.3, y: 0.8 },
+ { x: 0.7, y: 0.8 }
+ ],
+ 6: [
+ { x: 0.3, y: 0.2 },
+ { x: 0.7, y: 0.2 },
+ { x: 0.3, y: 0.5 },
+ { x: 0.7, y: 0.5 },
+ { x: 0.3, y: 0.8 },
+ { x: 0.7, y: 0.8 }
+ ],
+ 7: [
+ { x: 0.3, y: 0.15 },
+ { x: 0.7, y: 0.15 },
+ { x: 0.5, y: 0.35 },
+ { x: 0.3, y: 0.55 },
+ { x: 0.7, y: 0.55 },
+ { x: 0.3, y: 0.85 },
+ { x: 0.7, y: 0.85 }
+ ],
+ 8: [
+ { x: 0.3, y: 0.15 },
+ { x: 0.7, y: 0.15 },
+ { x: 0.5, y: 0.35 },
+ { x: 0.3, y: 0.55 },
+ { x: 0.7, y: 0.55 },
+ { x: 0.5, y: 0.65 },
+ { x: 0.3, y: 0.85 },
+ { x: 0.7, y: 0.85 }
+ ],
+ 9: [
+ { x: 0.3, y: 0.15 },
+ { x: 0.7, y: 0.15 },
+ { x: 0.5, y: 0.35 },
+ { x: 0.2, y: 0.5 },
+ { x: 0.5, y: 0.5 },
+ { x: 0.8, y: 0.5 },
+ { x: 0.5, y: 0.65 },
+ { x: 0.3, y: 0.85 },
+ { x: 0.7, y: 0.85 }
+ ],
+ 10: [
+ { x: 0.3, y: 0.15 },
+ { x: 0.7, y: 0.15 },
+ { x: 0.3, y: 0.35 },
+ { x: 0.7, y: 0.35 },
+ { x: 0.5, y: 0.5 },
+ { x: 0.3, y: 0.65 },
+ { x: 0.7, y: 0.65 },
+ { x: 0.3, y: 0.85 },
+ { x: 0.7, y: 0.85 }
+ ]
+}
+
+export function calculateSuitPositions(rank, cardWidth, cardHeight, symbolSize = 60) {
+ const positions = LAYOUT_POSITIONS[rank] || LAYOUT_POSITIONS[1]
+
+ return positions.map(pos => ({
+ x: pos.x * cardWidth - symbolSize / 2,
+ y: pos.y * cardHeight - symbolSize / 2,
+ width: symbolSize,
+ height: symbolSize
+ }))
+}
+
+export function getCornerPositions(cardWidth, cardHeight) {
+ return {
+ topLeft: { x: 50, y: 50 },
+ topRight: { x: cardWidth - 100, y: 50 },
+ bottomLeft: { x: 50, y: cardHeight - 100 },
+ bottomRight: { x: cardWidth - 100, y: cardHeight - 100 }
+ }
+}
+
+export function getSuitSymbol(suit) {
+ const symbols = {
+ spade: '♠',
+ heart: '♥',
+ club: '♣',
+ diamond: '♦'
+ }
+ return symbols[suit] || '♠'
+}
+
+export function getSuitColor(suit, templateColors) {
+ if (templateColors && templateColors[suit]) {
+ return templateColors[suit]
+ }
+
+ const colors = {
+ spade: '#000000',
+ heart: '#FF0000',
+ club: '#000000',
+ diamond: '#FF0000'
+ }
+ return colors[suit] || '#000000'
+}
+
+export function isRedSuit(suit) {
+ return suit === 'heart' || suit === 'diamond'
+}
+
+export function isBlackSuit(suit) {
+ return suit === 'spade' || suit === 'club'
+}
\ No newline at end of file
diff --git a/frontend/src/utils/symmetry.js b/frontend/src/utils/symmetry.js
new file mode 100644
index 0000000..f1eef43
--- /dev/null
+++ b/frontend/src/utils/symmetry.js
@@ -0,0 +1,81 @@
+import { fabric } from 'fabric'
+
+export async function createSymmetricalImage(originalImage, canvasWidth, canvasHeight) {
+ const imgWidth = originalImage.width
+ const imgHeight = originalImage.height
+ const halfHeight = imgHeight / 2
+
+ const topHalf = new fabric.Image(originalImage, {
+ clipPath: new fabric.Rect({
+ width: imgWidth,
+ height: halfHeight,
+ originX: 'left',
+ originY: 'top'
+ }),
+ top: 0,
+ scaleX: canvasWidth / imgWidth,
+ scaleY: (canvasHeight / 2) / halfHeight
+ })
+
+ const bottomHalf = new fabric.Image(originalImage, {
+ clipPath: new fabric.Rect({
+ width: imgWidth,
+ height: halfHeight,
+ originX: 'left',
+ originY: 'top',
+ top: halfHeight
+ }),
+ top: canvasHeight / 2,
+ scaleX: canvasWidth / imgWidth,
+ scaleY: -(canvasHeight / 2) / halfHeight,
+ flipY: true
+ })
+
+ const group = new fabric.Group([topHalf, bottomHalf], {
+ left: 0,
+ top: 0,
+ width: canvasWidth,
+ height: canvasHeight
+ })
+
+ return group
+}
+
+export async function loadAndProcessImage(imageUrl) {
+ return new Promise((resolve, reject) => {
+ fabric.Image.fromURL(imageUrl, (img) => {
+ if (!img) {
+ reject(new Error('Failed to load image'))
+ return
+ }
+ resolve(img)
+ }, { crossOrigin: 'anonymous' })
+ })
+}
+
+export function applySymmetryToFaceCard(canvas, imageUrl, cardWidth, cardHeight) {
+ return new Promise((resolve, reject) => {
+ fabric.Image.fromURL(imageUrl, async (originalImage) => {
+ if (!originalImage) {
+ reject(new Error('Failed to load image'))
+ return
+ }
+
+ const symmetricalGroup = await createSymmetricalImage(
+ originalImage.getElement(),
+ cardWidth - 100,
+ cardHeight - 100
+ )
+
+ symmetricalGroup.set({
+ left: 50,
+ top: 50,
+ selectable: true
+ })
+
+ canvas.add(symmetricalGroup)
+ canvas.renderAll()
+ resolve(symmetricalGroup)
+ }, { crossOrigin: 'anonymous' })
+ })
+}
\ No newline at end of file