Implement Django backend and Vue frontend structure

- Django backend with projects, templates, exports apps
- SQLite database models for Project, Asset, CardLayer
- REST API endpoints for project management
- Vue frontend with Vite, Element Plus, Fabric.js
- Home page for project selection
- Editor page with Fabric.js canvas integration
This commit is contained in:
Poker Design Developer
2026-05-31 14:55:01 +08:00
parent 00ac63b85c
commit 48629736f4
31 changed files with 1737 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
<h2>扑克牌设计管理系统 - 核心架构</h2>
<p class="subtitle">个人从零开始设计完整扑克牌系列</p>
<div class="section">
<h3>系统核心功能模块</h3>
<div class="cards">
<div class="card" data-choice="upload" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px; text-align: center; color: white; border-radius: 8px;">
<div style="font-size: 48px; margin-bottom: 10px;">??</div>
<div style="font-size: 14px; font-weight: 600;">图案上传</div>
</div>
</div>
<div class="card-body">
<h3>图案素材管理</h3>
<p>上传花色图案、数字字体、JQK人像、大小王图片、背面图案等设计素材</p>
</div>
</div>
<div class="card" data-choice="design" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); padding: 40px; text-align: center; color: white; border-radius: 8px;">
<div style="font-size: 48px; margin-bottom: 10px;">??</div>
<div style="font-size: 14px; font-weight: 600;">实时设计</div>
</div>
</div>
<div class="card-body">
<h3>可视化设计编辑器</h3>
<p>实时预览54张牌面调整布局、颜色、字体等设计元素</p>
</div>
</div>
<div class="card" data-choice="export" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); padding: 40px; text-align: center; color: white; border-radius: 8px;">
<div style="font-size: 48px; margin-bottom: 10px;">??</div>
<div style="font-size: 14px; font-weight: 600;">批量导出</div>
</div>
</div>
<div class="card-body">
<h3>PNG导出系统</h3>
<p>一键导出整套54张扑克牌为PNG格式支持多种分辨率</p>
</div>
</div>
</div>
</div>
<div class="section">
<h3>扑克牌设计规范</h3>
<div class="mockup">
<div class="mockup-header">标准扑克牌结构54张</div>
<div class="mockup-body">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; padding: 20px;">
<div style="border: 2px solid #e0e0e0; border-radius: 8px; padding: 16px; background: #f9f9f9;">
<div style="font-weight: 600; margin-bottom: 8px; color: #333;">数字牌 (40张)</div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
? 4种花色 × 10张 (A, 2-10)<br>
? 左右对称布局<br>
? 中间花色图案均匀分布<br>
? 四角标注点数与花色
</div>
</div>
<div style="border: 2px solid #e0e0e0; border-radius: 8px; padding: 16px; background: #f9f9f9;">
<div style="font-weight: 600; margin-bottom: 8px; color: #333;">花牌 JQK (12张)</div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
? 4种花色 × 3张 (J, Q, K)<br>
? <strong>中心对称设计</strong><br>
? 人像上下倒置可正常观看<br>
? 沿中轴线对称绘制
</div>
</div>
<div style="border: 2px solid #e0e0e0; border-radius: 8px; padding: 16px; background: #f9f9f9;">
<div style="font-weight: 600; margin-bottom: 8px; color: #333;">大小王 (2张)</div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
? 大王、小王各1张<br>
? 无花色区分<br>
? 独立图案设计<br>
? 视觉对称原则
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>设计工作流程</h3>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 20px; background: #f5f5f5; border-radius: 8px;">
<div style="text-align: center; flex: 1;">
<div style="width: 60px; height: 60px; background: #667eea; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; color: white; font-weight: 600;">1</div>
<div style="font-size: 13px; font-weight: 600;">上传素材</div>
</div>
<div style="font-size: 24px; color: #999;"></div>
<div style="text-align: center; flex: 1;">
<div style="width: 60px; height: 60px; background: #f093fb; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; color: white; font-weight: 600;">2</div>
<div style="font-size: 13px; font-weight: 600;">配置样式</div>
</div>
<div style="font-size: 24px; color: #999;"></div>
<div style="text-align: center; flex: 1;">
<div style="width: 60px; height: 60px; background: #4facfe; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; color: white; font-weight: 600;">3</div>
<div style="font-size: 13px; font-weight: 600;">实时预览</div>
</div>
<div style="font-size: 24px; color: #999;"></div>
<div style="text-align: center; flex: 1;">
<div style="width: 60px; height: 60px; background: #00f2fe; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; color: white; font-weight: 600;">4</div>
<div style="font-size: 13px; font-weight: 600;">导出PNG</div>
</div>
</div>
</div>

1
backend/apps/__init__.py Normal file
View File

@@ -0,0 +1 @@
# apps/__init__.py

View File

@@ -0,0 +1 @@
# apps/exports/__init__.py

View File

@@ -0,0 +1,7 @@
from django.urls import path
from .views import export_project, export_single_card
urlpatterns = [
path('projects/<str:pk>/export/', export_project, name='export-project'),
path('projects/<str:pk>/export/<str:card_key>/', export_single_card, name='export-single-card'),
]

View File

@@ -0,0 +1,196 @@
from PIL import Image, ImageDraw
import io
import os
def load_image(file_path, scale=1):
"""加载图片并应用缩放"""
img = Image.open(file_path).convert('RGBA')
if scale > 1:
new_size = (int(img.width * scale), int(img.height * scale))
img = img.resize(new_size, Image.LANCZOS)
return img
def generate_symmetrical_face_card(original_image_path, scale=1):
"""
生成JQK中心对称图案
输入:原始图片路径
输出:中心对称的图像数组(上半部分、下半部分)
"""
original = load_image(original_image_path, scale)
width, height = original.size
half_height = height // 2
# 创建上半部分
top_half = original.crop((0, 0, width, half_height))
# 创建下半部分并翻转
bottom_half = original.crop((0, half_height, width, height))
bottom_half = bottom_half.transpose(Image.FLIP_TOP_BOTTOM)
return top_half, bottom_half
def render_background(canvas, layer, scale):
"""渲染背景层"""
if layer.properties:
properties = layer.properties
width = canvas.size[0]
height = canvas.size[1]
# 解析color如 '#FF0000' 或 'rgb(255,0,0)'
bg_color = properties.get('color', '#FFFFFF')
# 创建背景矩形
draw = ImageDraw.Draw(canvas, 'RGBA')
draw.rectangle(((0, 0), (width, height)), fill=bg_color + 'FF')
# 如果有纹理或图案路径
texture_path = properties.get('texture_path')
if texture_path and os.path.exists(texture_path):
texture = load_image(texture_path, scale)
bg_height = height // 4
for y in range(0, height, bg_height):
canvas.paste(texture, (0, y), texture)
def render_image_layer(canvas, project, layer, scale):
"""渲染图片层(人像、花色等)"""
if not layer.file_ref or not layer.file_ref.file_path:
return
asset_path = os.path.join(project.media_root, layer.file_ref.file_path)
if not os.path.exists(asset_path):
return
image = load_image(asset_path, scale)
# 获取位置信息
properties = layer.properties or {}
x = properties.get('x', 0)
y = properties.get('y', 0)
width = properties.get('width', image.size[0])
height = properties.get('height', image.size[1])
# 计算实际坐标
canvas_width, canvas_height = canvas.size
actual_x = (x / project.card_width) * canvas_width
actual_y = (y / project.card_height) * canvas_height
# 计算实际尺寸
actual_w = (width / project.card_width) * canvas_width
actual_h = (height / project.card_height) * canvas_height
# 裁剪图片
cropped = image.copy()
cropped.thumbnail((actual_w, actual_h), Image.LANCZOS)
# 计算居中位置
paste_x = actual_x + (actual_w - cropped.size[0]) / 2
paste_y = actual_y + (actual_h - cropped.size[1]) / 2
canvas.paste(cropped, (int(paste_x), int(paste_y)), cropped)
def render_text_layer(canvas, layer, scale):
"""渲染文字层"""
properties = layer.properties or {}
draw = ImageDraw.Draw(canvas, 'RGBA')
text = properties.get('text', '')
x = properties.get('x', 0)
y = properties.get('y', 0)
# 解析字体和颜色
font = properties.get('font', None)
if font and isinstance(font, dict):
font_size = int(font.get('size', 24) * scale)
font_path = font.get('path')
from PIL import ImageFont
if font_path and os.path.exists(font_path):
try:
custom_font = ImageFont.truetype(font_path, font_size)
except:
custom_font = None
else:
custom_font = None
else:
from PIL import ImageFont
custom_font = ImageFont.load_default()
# 转换颜色
color = properties.get('color', '#000000')
if color.startswith('#'):
r = int(color[1:3], 16)
g = int(color[3:5], 16)
b = int(color[5:7], 16)
fill = (r, g, b, 255)
else:
fill = (0, 0, 0, 255)
# 计算实际坐标和尺寸
canvas_width, canvas_height = canvas.size
actual_x = (x / project.card_width) * canvas_width
actual_y = (y / project.card_height) * canvas_height
bbox = draw.textbbox((0, 0), text, font=custom_font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 居中计算
paste_x = actual_x + (canvas_width * project.card_width * 0.3 - text_width) / 2
paste_y = actual_y + (canvas_height * project.card_height * 0.5 - text_height) / 2
draw.text((int(paste_x), int(paste_y)), text, font=custom_font, fill=fill)
def generate_card_png(project, card_key, resolution='standard', scale_map={ 'standard': 1, 'hd': 2, 'ultra-hd': 4 }):
"""
生成单张牌的PNG图片
Args:
project: Project对象
card_key: 牌面key'hearts-A', 'spades-K', 'joker-big'
resolution: 分辨率standard/hd/ultra-hd
scale_map: 分辨率对应的缩放比例
Returns:
Image对象
"""
scale = scale_map.get(resolution, 1)
# 创建基础画布
# 牌面坐标系
x_offset = int(50 * scale)
y_offset = int(50 * scale)
draw_width = int((project.card_width - 100) * scale)
draw_height = int((project.card_height - 100) * scale)
canvas = Image.new('RGBA', (draw_width, draw_height))
draw = ImageDraw.Draw(canvas, 'RGBA')
draw.rectangle(((0, 0), (draw_width, draw_height)), fill=(255, 255, 255, 255))
# 获取卡片类型的所有图层
layers = CardLayer.objects.filter(
project=project,
card_key=card_key,
visible=True
).order_by('z_index')
# 渲染各图层
for layer in layers:
layer_type = layer.layer_type
if layer_type == 'background':
render_background(canvas, layer, scale)
elif layer_type == 'image':
render_image_layer(canvas, project, layer, scale)
elif layer_type == 'text':
render_text_layer(canvas, layer, scale)
return canvas

View File

@@ -0,0 +1,96 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from django.http import HttpResponse
from ..projects.models import Project
from .utils import generate_card_png
import zipfile
import io
import os
@api_view(['POST'])
def export_project(request, pk):
"""
批量导出整副牌为ZIP文件
请求体: { "resolution": "standard", "cards": "all" }
"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
return Response({'error': 'Project not found'}, status=status.HTTP_404_NOT_FOUND)
resolution = request.data.get('resolution', 'standard')
cards_filter = request.data.get('cards', 'all')
# 确定要导出的牌
cards = []
if cards_filter == 'all':
# 生成所有54张牌
for suit in ['spade', 'heart', 'club', 'diamond']:
for rank in ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10']:
cards.append(f"{suit}-{rank}")
for face in ['J', 'Q', 'K']:
cards.append(f"{suit}-{face}")
cards.extend(['joker-big', 'joker-small'])
if project.export_include_back:
cards.append('back')
else:
cards = cards_filter if isinstance(cards_filter, list) else [cards_filter]
# 创建ZIP文件
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for card_key in cards:
try:
png = generate_card_png(project, card_key, resolution)
img_buffer = io.BytesIO()
png.save(img_buffer, format='PNG')
img_buffer.seek(0)
zip_file.writestr(f"{card_key}.png", img_buffer.getvalue())
except Exception as e:
# 记录错误但继续处理其他牌
print(f"Error generating {card_key}: {str(e)}")
continue
zip_buffer.seek(0)
# 保存到media目录
export_dir = os.path.join('media', 'export', str(project.id))
os.makedirs(export_dir, exist_ok=True)
zip_path = os.path.join(export_dir, 'cards.zip')
with open(zip_path, 'wb') as f:
f.write(zip_buffer.getvalue())
return Response({
'download_url': f'/media/export/{project.id}/cards.zip',
'card_count': len(cards)
})
@api_view(['GET'])
def export_single_card(request, pk, card_key):
"""
导出单张牌PNG
"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
return Response({'error': 'Project not found'}, status=status.HTTP_404_NOT_FOUND)
resolution = request.query_params.get('resolution', 'standard')
try:
png = generate_card_png(project, card_key, resolution)
img_buffer = io.BytesIO()
png.save(img_buffer, format='PNG')
img_buffer.seek(0)
response = HttpResponse(img_buffer, content_type='image/png')
response['Content-Disposition'] = f'attachment; filename="{card_key}.png"'
return response
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1 @@
# apps/projects/__init__.py

View File

@@ -0,0 +1,56 @@
from django.db import models
import uuid
class Project(models.Model):
"""项目配置模型"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=100)
template_id = models.CharField(max_length=50, default='classic')
card_width = models.IntegerField(default=750)
card_height = models.IntegerField(default=1050)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# 导出设置
export_resolution = models.CharField(max_length=20, default='standard')
export_include_back = models.BooleanField(default=True)
def __str__(self):
return self.name
class Meta:
ordering = ['-updated_at']
class Asset(models.Model):
"""项目素材模型"""
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'
width = models.IntegerField(null=True)
height = models.IntegerField(null=True)
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.asset_type}:{self.asset_key}"
class CardLayer(models.Model):
"""牌面图层配置模型"""
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)
layer_type = models.CharField(max_length=20) # 'background', 'border', 'image', 'text'
visible = models.BooleanField(default=True)
locked = models.BooleanField(default=False)
opacity = models.FloatField(default=1.0)
z_index = models.IntegerField(default=0)
# 图层属性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}"
class Meta:
ordering = ['card_key', 'z_index']

View File

@@ -0,0 +1,29 @@
from rest_framework import serializers
from .models import Project, Asset, CardLayer
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = '__all__'
class AssetSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = '__all__'
class CardLayerSerializer(serializers.ModelSerializer):
class Meta:
model = CardLayer
fields = '__all__'
class ProjectDetailSerializer(serializers.ModelSerializer):
assets = AssetSerializer(many=True, read_only=True)
layers = CardLayerSerializer(many=True, read_only=True)
class Meta:
model = Project
fields = '__all__'

View File

@@ -0,0 +1,7 @@
from django.urls import path
from .views import project_list, project_detail
urlpatterns = [
path('', project_list, name='project-list'),
path('<str:pk>/', project_detail, name='project-detail'),
]

View File

@@ -0,0 +1,45 @@
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
@api_view(['GET', 'POST'])
def project_list(request):
"""获取项目列表或创建新项目"""
if request.method == 'GET':
projects = Project.objects.all()
serializer = ProjectSerializer(projects, many=True)
return Response(serializer.data)
elif request.method == 'POST':
serializer = ProjectSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET', 'PUT', 'DELETE'])
def project_detail(request, pk):
"""获取、更新或删除项目"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
return Response({'error': 'Project not found'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
serializer = ProjectDetailSerializer(project)
return Response(serializer.data)
elif request.method == 'PUT':
serializer = ProjectSerializer(project, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
elif request.method == 'DELETE':
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1 @@
# apps/templates/__init__.py

View File

@@ -0,0 +1,38 @@
from django.db import models
import uuid
class CardTemplate(models.Model):
"""扑克牌模板模型"""
id = models.CharField(max_length=50, primary_key=True) # 'classic', 'modern', etc.
name = models.CharField(max_length=100)
description = models.TextField()
preview_image = models.ImageField(upload_to='templates/previews/', null=True)
# 默认配色方案
color_spade = models.CharField(max_length=20, default='#000000')
color_heart = models.CharField(max_length=20, default='#FF0000')
color_club = models.CharField(max_length=20, default='#000000')
color_diamond = models.CharField(max_length=20, default='#FF0000')
color_background = models.CharField(max_length=20, default='#FFFFFF')
# 默认素材路径JSON
default_assets = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Meta:
ordering = ['name']
class SuitSymbol(models.Model):
"""花色图案模板"""
template = models.ForeignKey(CardTemplate, on_delete=models.CASCADE, related_name='suit_symbols')
suit_name = models.CharField(max_length=20) # 'spade', 'heart', 'club', 'diamond'
svg_path = models.TextField() # SVG路径数据
color = models.CharField(max_length=20)
def __str__(self):
return f"{self.template.name} - {self.suit_name}"

View File

@@ -0,0 +1,58 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import CardTemplate
@api_view(['GET'])
def template_list(request):
"""获取所有模板列表"""
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,
},
})
return Response(data)
@api_view(['GET'])
def template_detail(request, pk):
"""获取模板详情"""
try:
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)

22
backend/manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poker_api.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1 @@
# poker_api/__init__.py

View File

@@ -0,0 +1,79 @@
"""
Django settings for poker_api project.
"""
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'django-insecure-poker-design-local-dev-key-change-in-production'
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'apps.projects',
'apps.templates',
'apps.exports',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.middleware.common.CommonMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'poker_api.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
],
},
},
]
WSGI_APPLICATION = 'poker_api.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
]
CORS_ALLOW_CREDENTIALS = True
REST_FRAMEWORK = {
'DEFAULT_PAGINATION': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}

14
backend/poker_api/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('api/', include([
path('', include('apps.projects.urls')),
path('', include('apps.templates.urls')),
path('', include('apps.exports.urls')),
])),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -0,0 +1,9 @@
"""
WSGI config for poker_api project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poker_api.settings')
application = get_wsgi_application()

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
# Django核心
Django>=5.0,<6.0
djangorestframework>=3.14
django-cors-headers>=4.3
# 图像处理
Pillow>=10.0
# 其他工具
python-dotenv>=1.0

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扑克牌设计管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "poker-design-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"element-plus": "^2.5.6",
"axios": "^1.6.7",
"fabric": "^6.4.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.6",
"sass": "^1.72.0"
}
}

23
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
</style>

View File

@@ -0,0 +1,15 @@
import axios from 'axios'
const API_BASE = '/api'
export async function exportProject(projectId, resolution = 'standard', cards = 'all') {
const response = await axios.post(`${API_BASE}/projects/${projectId}/export/`, {
resolution,
cards
})
return response.data
}
export function getExportUrl(projectId, cardKey, resolution = 'standard') {
return `${API_BASE}/projects/${projectId}/export/${cardKey}/?resolution=${resolution}`
}

View File

@@ -0,0 +1,27 @@
import axios from 'axios'
const API_BASE = '/api'
export async function getProjects() {
const response = await axios.get(`${API_BASE}/projects/`)
return response.data
}
export async function createProject(data) {
const response = await axios.post(`${API_BASE}/projects/`, data)
return response.data
}
export async function getProject(id) {
const response = await axios.get(`${API_BASE}/projects/${id}/`)
return response.data
}
export async function updateProject(id, data) {
const response = await axios.put(`${API_BASE}/projects/${id}/`, data)
return response.data
}
export async function deleteProject(id) {
await axios.delete(`${API_BASE}/projects/${id}/`)
}

View File

@@ -0,0 +1,13 @@
import axios from 'axios'
const API_BASE = '/api'
export async function getTemplates() {
const response = await axios.get(`${API_BASE}/templates/`)
return response.data
}
export async function getTemplate(id) {
const response = await axios.get(`${API_BASE}/templates/${id}/`)
return response.data
}

12
frontend/src/main.js Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Editor from '@/views/Editor.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/editor/:projectId?',
name: 'Editor',
component: Editor
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@@ -0,0 +1,481 @@
<template>
<el-container class="editor-container">
<el-header class="editor-header">
<div class="header-left">
<button @click="goBack" class="back-btn"> 返回</button>
<input
v-model="projectName"
@blur="saveProjectName"
class="project-name-input"
placeholder="项目名称"
/>
</div>
<div class="header-right">
<el-button type="primary" @click="exportAll">导出全部</el-button>
<el-button @click="exportSingle">导出当前</el-button>
</div>
</el-header>
<el-container class="editor-body">
<el-aside width="300px" class="left-panel">
<el-tabs v-model="leftTab">
<el-tab-pane label="素材库" name="assets">
<div class="asset-section">
<h3>花色图案</h3>
<div class="asset-grid">
<div v-for="suit in suits" :key="suit.id" class="asset-item">
<div class="asset-preview">{{ suit.symbol }}</div>
<button @click="uploadAsset('suit', suit.id)">上传</button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="图层" name="layers">
<div class="layer-list">
<div
v-for="layer in currentLayers"
:key="layer.id"
class="layer-item"
:class="{ active: selectedLayer === layer.id }"
@click="selectLayer(layer.id)"
>
<el-checkbox v-model="layer.visible" />
<span>{{ layer.name }}</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-aside>
<el-main class="canvas-area">
<div class="canvas-wrapper">
<canvas ref="canvasRef" id="main-canvas"></canvas>
</div>
</el-main>
</el-container>
<el-footer height="120px" class="card-list-footer">
<div class="card-tabs">
<div
v-for="suit in cardSuits"
:key="suit"
class="suit-tab"
:class="{ active: currentSuit === suit }"
@click="switchSuit(suit)"
>
{{ suit }}
</div>
</div>
<div class="card-thumbnails">
<div
v-for="card in currentCards"
:key="card.key"
class="card-thumb"
:class="{ active: currentCard === card.key }"
@click="selectCard(card.key)"
>
<div class="card-mini-preview">{{ card.label }}</div>
</div>
</div>
</el-footer>
</el-container>
</template>
<script setup>
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fabric } from 'fabric'
import { ElMessage } from 'element-plus'
import { getProject, updateProject } from '@/api/project'
import { exportProject, getExportUrl } from '@/api/export'
const route = useRoute()
const router = useRouter()
const canvasRef = ref(null)
const projectName = ref('')
const leftTab = ref('assets')
const selectedLayer = ref(null)
const currentSuit = ref('spade')
const currentCard = ref('spade-A')
const canvas = ref(null)
const projectId = computed(() => route.params.projectId)
const suits = [
{ id: 'spade', symbol: '♠', name: '黑桃' },
{ id: 'heart', symbol: '♥', name: '红桃' },
{ id: 'club', symbol: '♣', name: '梅花' },
{ id: 'diamond', symbol: '♦', name: '方块' }
]
const cardSuits = ['spade', 'heart', 'club', 'diamond']
const cardRanks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
const currentCards = computed(() => {
const cards = cardRanks.map(rank => ({
key: `${currentSuit.value}-${rank}`,
label: `${getSuitSymbol(currentSuit.value)}${rank}`
}))
if (currentSuit.value === 'spade') {
cards.push({ key: 'joker-big', label: '大王' })
cards.push({ key: 'joker-small', label: '小王' })
}
return cards
})
const currentLayers = ref([
{ id: 'background', name: '背景层', visible: true },
{ id: 'border', name: '边框层', visible: true },
{ id: 'pattern', name: '图案层', visible: true },
{ id: 'text', name: '文字层', visible: true }
])
function getSuitSymbol(suit) {
const map = {
spade: '♠',
heart: '♥',
club: '♣',
diamond: '♦'
}
return map[suit] || ''
}
onMounted(async () => {
if (projectId.value) {
await loadProject()
}
await nextTick()
initCanvas()
})
async function loadProject() {
try {
const project = await getProject(projectId.value)
projectName.value = project.name
} catch (error) {
ElMessage.error('加载项目失败')
console.error(error)
}
}
function initCanvas() {
if (!canvasRef.value) return
canvas.value = new fabric.Canvas('main-canvas', {
width: 750,
height: 1050,
backgroundColor: '#ffffff',
selection: true
})
drawDefaultCard()
}
function drawDefaultCard() {
if (!canvas.value) return
canvas.value.clear()
canvas.value.setBackgroundColor('#ffffff', canvas.value.renderAll.bind(canvas.value))
const rect = new fabric.Rect({
left: 50,
top: 50,
width: 650,
height: 950,
fill: 'transparent',
stroke: '#e0e0e0',
strokeWidth: 2,
selectable: false
})
canvas.value.add(rect)
const suitSymbol = getSuitSymbol(currentSuit.value)
const rank = currentCard.value.split('-')[1] || 'A'
const topText = new fabric.Text(`${rank}${suitSymbol}`, {
left: 70,
top: 70,
fontSize: 48,
fill: currentSuit.value === 'heart' || currentSuit.value === 'diamond' ? '#FF0000' : '#000000',
selectable: true
})
canvas.value.add(topText)
const centerSymbol = new fabric.Text(suitSymbol, {
left: 375,
top: 525,
fontSize: 120,
fill: currentSuit.value === 'heart' || currentSuit.value === 'diamond' ? '#FF0000' : '#000000',
selectable: true,
originX: 'center',
originY: 'center'
})
canvas.value.add(centerSymbol)
const bottomText = new fabric.Text(`${rank}${suitSymbol}`, {
left: 630,
top: 930,
fontSize: 48,
fill: currentSuit.value === 'heart' || currentSuit.value === 'diamond' ? '#FF0000' : '#000000',
selectable: true,
angle: 180,
originX: 'center',
originY: 'center'
})
canvas.value.add(bottomText)
canvas.value.renderAll()
}
function switchSuit(suit) {
currentSuit.value = suit
currentCard.value = `${suit}-A`
drawDefaultCard()
}
function selectCard(cardKey) {
currentCard.value = cardKey
drawDefaultCard()
}
function selectLayer(layerId) {
selectedLayer.value = layerId
}
function uploadAsset(type, id) {
ElMessage.info('素材上传功能开发中...')
}
async function saveProjectName() {
if (!projectId.value) return
try {
await updateProject(projectId.value, { name: projectName.value })
ElMessage.success('项目名称已保存')
} catch (error) {
ElMessage.error('保存失败')
}
}
async function exportAll() {
if (!projectId.value) return
try {
const result = await exportProject(projectId.value, 'standard', 'all')
window.open(result.download_url, '_blank')
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
console.error(error)
}
}
function exportSingle() {
if (!projectId.value) return
const url = getExportUrl(projectId.value, currentCard.value, 'standard')
window.open(url, '_blank')
ElMessage.success('开始导出')
}
function goBack() {
router.push('/')
}
</script>
<style scoped>
.editor-container {
height: 100vh;
display: flex;
flex-direction: column;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
border-bottom: 1px solid #e0e0e0;
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
}
.back-btn {
background: transparent;
border: none;
color: #666;
cursor: pointer;
font-size: 16px;
padding: 8px 12px;
border-radius: 4px;
}
.back-btn:hover {
background: #f5f5f5;
}
.project-name-input {
border: none;
font-size: 18px;
font-weight: 600;
padding: 5px 10px;
border-radius: 4px;
}
.project-name-input:focus {
outline: none;
background: #f5f5f5;
}
.header-right {
display: flex;
gap: 10px;
}
.editor-body {
flex: 1;
overflow: hidden;
}
.left-panel {
background: #fafafa;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
}
.canvas-area {
display: flex;
justify-content: center;
align-items: center;
background: #e8e8e8;
padding: 20px;
}
.canvas-wrapper {
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: auto;
}
.card-list-footer {
background: #ffffff;
border-top: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
padding: 10px 20px;
}
.card-tabs {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.suit-tab {
padding: 5px 15px;
background: #f5f5f5;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
.suit-tab.active {
background: #667eea;
color: white;
}
.card-thumbnails {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 5px;
}
.card-thumb {
width: 50px;
height: 70px;
background: #f5f5f5;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
}
.card-thumb.active {
background: #667eea;
color: white;
}
.card-mini-preview {
font-size: 14px;
font-weight: 600;
}
.asset-section {
padding: 15px;
}
.asset-section h3 {
margin-bottom: 10px;
font-size: 14px;
color: #333;
}
.asset-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.asset-item {
text-align: center;
}
.asset-preview {
width: 100%;
height: 60px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 5px;
}
.layer-list {
padding: 10px;
}
.layer-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 4px;
cursor: pointer;
}
.layer-item:hover {
background: #f0f0f0;
}
.layer-item.active {
background: #e6f0ff;
}
</style>

296
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,296 @@
<template>
<el-container class="home-container">
<el-header>
<h1>扑克牌设计管理系统</h1>
<button @click="createNewProject" class="create-btn">
<el-icon><Plus /></el-icon>
创建新项目
</button>
</el-header>
<el-main>
<h2>选择或创建项目</h2>
<el-tabs v-model="activeTab" type="card">
<el-tab-pane label="模板系列" name="templates">
<div class="template-grid">
<div
v-for="template in templates"
:key="template.id"
class="template-card"
@click="selectTemplate(template.id)"
>
<div class="template-preview">
<img :src="template.preview_image" alt="" v-if="template.preview_image">
<div class="placeholder-preview">{{ template.name.substring(0,2) }}</div>
</div>
<h3>{{ template.name }}</h3>
<p>{{ template.description }}</p>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="已有项目" name="existing">
<div class="project-list">
<div v-for="project in projects" :key="project.id" class="project-item">
<div class="project-info">
<h3>{{ project.name }}</h3>
<p>创建于: {{ formatDate(project.created_at) }}</p>
</div>
<div class="project-actions">
<el-button @click="editProject(project.id)">编辑</el-button>
<el-button type="danger" @click="deleteProject(project.id)">删除</el-button>
</div>
</div>
<div v-if="projects.length === 0" class="empty-state">
暂无项目请创建新项目
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-main>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Plus } from '@element-plus/icons-vue'
import { getProjects, createProject as apiCreateProject, deleteProject as apiDeleteProject, getTemplates } from '@/api/project'
import { getTemplate as apiGetTemplate } from '@/api/template'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const activeTab = ref('templates')
const projects = ref([])
const templates = ref([])
onMounted(async () => {
await loadProjects()
await loadTemplates()
})
async function loadProjects() {
try {
projects.value = await getProjects()
} catch (error) {
console.error('Failed to load projects:', error)
}
}
async function loadTemplates() {
try {
templates.value = await getTemplates()
} catch (error) {
console.error('Failed to load templates:', error)
}
}
async function createNewProject() {
if (activeTab.value === 'existing' && projects.value.length > 0) {
ElMessage.warning('请先选择模板')
return
}
try {
const templateId = activeTab.value === 'templates'
? templates.value[0]?.id || 'classic'
: 'classic'
const newProject = await apiCreateProject({
name: `未命名项目 ${new Date().toLocaleDateString()}`,
template_id: templateId
})
router.push(`/editor/${newProject.id}`)
ElMessage.success('项目创建成功')
} catch (error) {
ElMessage.error('项目创建失败')
}
}
async function selectTemplate(templateId) {
try {
const template = await apiGetTemplate(templateId)
const newProject = await apiCreateProject({
name: `${template.name} - 新项目`,
template_id: templateId
})
router.push(`/editor/${newProject.id}`)
ElMessage.success('项目创建成功')
} catch (error) {
ElMessage.error('项目创建失败')
}
}
async function editProject(projectId) {
router.push(`/editor/${projectId}`)
}
async function deleteProject(projectId) {
try {
await ElMessageBox.confirm(
'确定要删除这个项目吗?此操作无法撤销。',
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await apiDeleteProject(projectId)
await loadProjects()
ElMessage.success('项目已删除')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
function formatDate(dateString) {
return new Date(dateString).toLocaleString('zh-CN')
}
</script>
<style scoped>
.home-container {
height: 100vh;
}
.el-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0 30px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.el-header h1 {
font-size: 24px;
font-weight: 600;
}
.create-btn {
background: white;
color: #667eea;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.3s;
}
.create-btn:hover {
background: #f0f0f0;
}
.el-main {
padding: 30px;
}
h2 {
margin-bottom: 20px;
font-weight: 600;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.template-card {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
}
.template-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.template-preview {
width: 100%;
height: 150px;
background: #f5f5f5;
border-radius: 4px;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.template-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-preview {
font-size: 48px;
color: #999;
}
template-card h3 {
font-size: 16px;
margin-bottom: 8px;
color: #333;
}
template-card p {
font-size: 14px;
color: #666;
line-height: 1.5;
}
.project-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.project-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #f5f5f5;
border-radius: 6px;
}
.project-info h3 {
margin: 0 0 5px 0;
color: #333;
}
.project-info p {
margin: 0;
font-size: 14px;
color: #666;
}
.project-actions {
display: flex;
gap: 10px;
}
.empty-state {
text-align: center;
padding: 50px;
color: #999;
font-size: 16px;
}
</style>

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
},
'/media': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})