重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器

This commit is contained in:
Developer
2026-06-01 17:11:06 +08:00
parent bde508dcfe
commit 2a36aa593c
20 changed files with 2326 additions and 853 deletions

View File

@@ -1,196 +1,401 @@
from PIL import Image, ImageDraw
"""
扑克牌渲染工具 - 使用 Pillow 在服务器端生成牌面 PNG。
渲染图层结构(从下到上):
1. 背景层(颜色或图片)
2. 边框层
3. 主体层(数字牌的花色阵列 / JQK 的人物对称 / 大小王的图案 + JOKER 文字)
4. 角标层(左上 + 右下 旋转 180°
"""
from PIL import Image, ImageDraw, ImageFont
import io
import os
# 标准扑克牌花色符号(用作默认渲染)
SUIT_TEXT = {
'spade': '',
'heart': '',
'club': '',
'diamond': '',
}
RED_SUITS = {'heart', 'diamond'}
BLACK_SUITS = {'spade', 'club'}
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
# 数字牌点数 1-10 的花色位置(相对坐标 0~1
LAYOUT_POSITIONS = {
1: [(0.50, 0.50)],
2: [(0.50, 0.25), (0.50, 0.75)],
3: [(0.50, 0.20), (0.50, 0.50), (0.50, 0.80)],
4: [(0.30, 0.25), (0.70, 0.25), (0.30, 0.75), (0.70, 0.75)],
5: [(0.30, 0.20), (0.70, 0.20), (0.50, 0.50), (0.30, 0.80), (0.70, 0.80)],
6: [(0.30, 0.20), (0.70, 0.20), (0.30, 0.50), (0.70, 0.50), (0.30, 0.80), (0.70, 0.80)],
7: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.35), (0.30, 0.55), (0.70, 0.55), (0.30, 0.85), (0.70, 0.85)],
8: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.32), (0.30, 0.50), (0.70, 0.50), (0.50, 0.68), (0.30, 0.85), (0.70, 0.85)],
9: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.30), (0.22, 0.50), (0.50, 0.50), (0.78, 0.50), (0.50, 0.70), (0.30, 0.85), (0.70, 0.85)],
10: [(0.30, 0.15), (0.70, 0.15), (0.30, 0.35), (0.70, 0.35), (0.50, 0.50), (0.30, 0.65), (0.70, 0.65), (0.30, 0.85), (0.70, 0.85)],
}
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 hex_to_rgba(hex_color, alpha=255):
"""将 #RRGGBB 转为 (r, g, b, a)"""
if not hex_color:
return (255, 255, 255, alpha)
h = hex_color.lstrip('#')
if len(h) == 3:
h = ''.join(c * 2 for c in h)
try:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
except Exception:
return (255, 255, 255, alpha)
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 is_red(suit):
return suit in RED_SUITS
def render_image_layer(canvas, project, layer, scale):
"""渲染图片层(人像、花色等)"""
if not layer.file_ref or not layer.file_ref.file_path:
def is_black(suit):
return suit in BLACK_SUITS
def get_effective_design(project, card_key):
"""合并项目级设计与单牌覆盖,返回最终 design dict"""
base = dict(project.design or {})
overrides = (project.card_overrides or {}).get(card_key, {})
if overrides:
base.update(overrides)
return base
def load_image_safe(file_path):
"""加载图片,找不到时返回 None"""
if not file_path:
return None
if not os.path.exists(file_path):
return None
try:
return Image.open(file_path).convert('RGBA')
except Exception:
return None
def draw_background(canvas, design, project, card_key):
"""绘制背景层"""
w, h = canvas.size
bg_color = design.get('background_color', '#FFFFFF') or '#FFFFFF'
canvas.paste(hex_to_rgba(bg_color, 255), (0, 0, w, h))
bg_image = design.get('background_image')
if bg_image:
bg_path = os.path.join(project._meta.get_field('design').model._meta.app_config and
os.path.dirname(__file__) or '',
bg_image)
# 优先尝试绝对路径 / media 根
candidates = [bg_image,
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'media', bg_image)]
for path in candidates:
img = load_image_safe(path)
if img:
img = img.resize((w, h), Image.LANCZOS)
canvas.alpha_composite(img)
break
def draw_border(canvas, design):
"""绘制边框"""
w, h = canvas.size
color = design.get('border_color', '#333333') or '#333333'
width = int(design.get('border_width', 0) or 0)
if width <= 0:
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)
draw = ImageDraw.Draw(canvas)
half = max(1, width // 2)
draw.rectangle(((half, half), (w - half, h - half)),
outline=hex_to_rgba(color, 255), width=width)
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):
def make_text_font(family, size, bold=False):
"""根据字体族名尝试加载字体;找不到时退回默认"""
if not family:
return ImageFont.load_default()
# 仅在 Windows 上尝试用注册字体
if os.name == 'nt':
candidates = [
family,
family.replace(' ', '') + (' Bold' if bold else ''),
family + (' Bold' if bold else ''),
]
for name in candidates:
try:
custom_font = ImageFont.truetype(font_path, font_size)
except:
custom_font = None
# 走 Windows 字体名
return ImageFont.truetype(name + '.ttf', size)
except Exception:
pass
# 一些常见回退
try:
return ImageFont.truetype('arial.ttf', size)
except Exception:
pass
return ImageFont.load_default()
def draw_corner_index(canvas, design, suit, rank, project, card_key):
"""绘制左上角 + 右下角(旋转 180°的牌面角标
角标由 上方的 rank + 小花色 组成
"""
w, h = canvas.size
color = hex_to_rgba(design.get('suit_symbols', {}).get(suit, {}).get('color')
or ('#E53935' if is_red(suit) else '#000000'), 255)
# 角标和中心花色大小(占牌面宽度比例)
corner_ratio = float(design.get('corner_size_ratio', 0.13) or 0.13)
pip_ratio = float(design.get('pip_size_ratio', 0.16) or 0.16)
corner_size = max(20, int(w * corner_ratio))
font = make_text_font(design.get('font_family', 'Times New Roman'), corner_size)
suit_font = make_text_font('Arial', int(corner_size * 0.9))
# 左上角
pad = max(8, int(w * 0.04))
rank_str = str(rank)
draw = ImageDraw.Draw(canvas)
rank_bbox = draw.textbbox((0, 0), rank_str, font=font)
rank_h = rank_bbox[3] - rank_bbox[1]
rank_w = rank_bbox[2] - rank_bbox[0]
suit_bbox = draw.textbbox((0, 0), SUIT_TEXT[suit], font=suit_font)
suit_h = suit_bbox[3] - suit_bbox[1]
suit_w = suit_bbox[2] - suit_bbox[0]
# rank 在上、suit 在下
top_y = pad
canvas_rgba = canvas
draw.text((pad, top_y), rank_str, font=font, fill=color)
draw.text((pad, top_y + rank_h + 2), SUIT_TEXT[suit], font=suit_font, fill=color)
# 右下角:旋转 180° 整体贴到 (w-pad, h-pad)
block_w = max(rank_w, suit_w)
block_h = rank_h + 2 + suit_h
# 透明小图旋转
corner_img = Image.new('RGBA', (block_w + 4, block_h + 4), (0, 0, 0, 0))
cdraw = ImageDraw.Draw(corner_img)
cdraw.text((0, 0), rank_str, font=font, fill=color)
cdraw.text((0, rank_h + 2), SUIT_TEXT[suit], font=suit_font, fill=color)
corner_img = corner_img.rotate(180)
canvas_rgba.alpha_composite(corner_img, (w - block_w - 4 - pad, h - block_h - 4 - pad))
def draw_number_card(canvas, design, suit, rank, project, card_key):
"""绘制数字牌:中心花色阵列"""
w, h = canvas.size
pip_ratio = float(design.get('pip_size_ratio', 0.16) or 0.16)
pip_size = max(40, int(w * pip_ratio))
suit_color = hex_to_rgba(design.get('suit_symbols', {}).get(suit, {}).get('color')
or ('#E53935' if is_red(suit) else '#000000'), 255)
font = make_text_font('Arial', pip_size)
# 决定位置:优先用项目里的 number_layout 覆盖默认
rank_int = int(rank) if str(rank).isdigit() else 1
default_positions = LAYOUT_POSITIONS.get(rank_int, LAYOUT_POSITIONS[1])
layout_key = str(rank_int)
user_overrides = (project.number_layout or {}).get(layout_key, [])
positions = []
for i, (fx, fy) in enumerate(default_positions):
if i < len(user_overrides) and isinstance(user_overrides[i], dict):
dx = float(user_overrides[i].get('dx', 0))
dy = float(user_overrides[i].get('dy', 0))
scale = float(user_overrides[i].get('scale', 1))
else:
custom_font = None
else:
from PIL import ImageFont
custom_font = ImageFont.load_default()
dx = dy = 0
scale = 1.0
positions.append((fx + dx, fy + dy, scale))
# 转换颜色
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)
draw = ImageDraw.Draw(canvas)
symbol = SUIT_TEXT[suit]
for fx, fy, scale in positions:
sz = max(20, int(pip_size * scale))
fnt = make_text_font('Arial', sz)
bbox = draw.textbbox((0, 0), symbol, font=fnt)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
cx = int(fx * w)
cy = int(fy * h)
draw.text((cx - tw // 2, cy - th // 2), symbol, font=fnt, fill=suit_color)
def generate_card_png(project, card_key, resolution='standard', scale_map={ 'standard': 1, 'hd': 2, 'ultra-hd': 4 }):
def draw_face_card(canvas, design, suit, rank, project, card_key, asset):
"""绘制 JQK 人物图:上半 + 上下翻转的下半,形成中心对称
资源找不到时退化为一个大花色符号 + 字母
"""
生成单张牌的PNG图片
w, h = canvas.size
# 主体区域:留出上下 25% 边距给角标
body_pad_x = int(w * 0.15)
body_pad_y_top = int(h * 0.13)
body_pad_y_bot = int(h * 0.13)
body_w = w - 2 * body_pad_x
body_h = h - body_pad_y_top - body_pad_y_bot
Args:
project: Project对象
card_key: 牌面key'hearts-A', 'spades-K', 'joker-big'
resolution: 分辨率standard/hd/ultra-hd
scale_map: 分辨率对应的缩放比例
if asset:
try:
img = asset.copy()
# 等比缩放 fill body_w x body_h
img_ratio = img.width / img.height
target_ratio = body_w / body_h
if img_ratio > target_ratio:
new_w = body_w
new_h = int(body_w / img_ratio)
else:
new_h = body_h
new_w = int(body_h * img_ratio)
img = img.resize((new_w, new_h), Image.LANCZOS)
Returns:
Image对象
"""
# 取上半部分 + 上下翻转的下半部分(取上半 = 下半 = 整个图按上下中线翻转)
top = img.crop((0, 0, img.width, img.height // 2))
bot = img.crop((0, img.height // 2, img.width, img.height)).transpose(Image.FLIP_TOP_BOTTOM)
# 拼接成 body_h 高的新图
full = Image.new('RGBA', (img.width, body_h), (0, 0, 0, 0))
top = top.resize((img.width, body_h // 2), Image.LANCZOS)
bot = bot.resize((img.width, body_h - body_h // 2), Image.LANCZOS)
full.paste(top, (0, 0), top)
full.paste(bot, (0, body_h // 2), bot)
full = full.resize((body_w, body_h), Image.LANCZOS)
canvas.alpha_composite(full, (body_pad_x, body_pad_y_top))
return
except Exception:
pass
# 退化:绘制大字 + 花色
suit_color = hex_to_rgba(design.get('suit_symbols', {}).get(suit, {}).get('color')
or ('#E53935' if is_red(suit) else '#000000'), 255)
big = max(80, int(h * 0.35))
fnt_rank = make_text_font('Times New Roman', big, bold=True)
fnt_suit = make_text_font('Arial', big)
draw = ImageDraw.Draw(canvas)
rb = draw.textbbox((0, 0), rank, font=fnt_rank)
rw = rb[2] - rb[0]
rh = rb[3] - rb[1]
draw.text(((w - rw) // 2, body_pad_y_top + (body_h - rh) // 2 - big // 2),
rank, font=fnt_rank, fill=suit_color)
sb = draw.textbbox((0, 0), SUIT_TEXT[suit], font=fnt_suit)
sw = sb[2] - sb[0]
sh = sb[3] - sb[1]
draw.text(((w - sw) // 2, body_pad_y_top + (body_h - sh) // 2 + big // 4),
SUIT_TEXT[suit], font=fnt_suit, fill=suit_color)
def draw_joker(canvas, design, which, project, card_key, asset):
"""绘制大小王:独立背景 + 主体图 + 文字"""
w, h = canvas.size
# 优先用单牌覆盖的背景色
bg_color = design.get('background_color', '#1B5E20' if which == 'big' else '#B71C1C')
canvas.paste(hex_to_rgba(bg_color, 255), (0, 0, w, h))
# 主体图
body_pad_x = int(w * 0.15)
body_pad_y_top = int(h * 0.15)
body_pad_y_bot = int(h * 0.20)
body_w = w - 2 * body_pad_x
body_h = h - body_pad_y_top - body_pad_y_bot
if asset:
try:
img = asset.copy()
img.thumbnail((body_w, body_h), Image.LANCZOS)
canvas.alpha_composite(img, (body_pad_x + (body_w - img.width) // 2,
body_pad_y_top + (body_h - img.height) // 2))
except Exception:
pass
else:
# 退化
fnt = make_text_font('Times New Roman', max(80, int(h * 0.25)), bold=True)
draw = ImageDraw.Draw(canvas)
text = 'JOKER'
color = (255, 255, 255, 255) if which == 'big' else (255, 255, 255, 255)
bb = draw.textbbox((0, 0), text, font=fnt)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color)
# 文字标识
label = 'BIG' if which == 'big' else 'SMALL'
text = 'JOKER'
fnt_label = make_text_font('Times New Roman', max(20, int(w * 0.06)), bold=True)
fnt_text = make_text_font('Times New Roman', max(16, int(w * 0.045)))
color = (255, 255, 255, 255)
draw = ImageDraw.Draw(canvas)
bb = draw.textbbox((0, 0), text, font=fnt_text)
tw = bb[2] - bb[0]
th = bb[3] - bb[1]
pad = max(8, int(w * 0.04))
# 左上
draw.text((pad, pad), text, font=fnt_text, fill=color)
draw.text((pad, pad + th + 2), label, font=fnt_label, fill=color)
# 右下
block = Image.new('RGBA', (tw + 4, 2 * th + 6), (0, 0, 0, 0))
bdraw = ImageDraw.Draw(block)
bdraw.text((0, 0), text, font=fnt_text, fill=color)
bdraw.text((0, th + 2), label, font=fnt_label, fill=color)
block = block.rotate(180)
canvas.alpha_composite(block, (w - tw - 4 - pad, h - 2 * th - 6 - pad))
def generate_card_png(project, card_key, resolution='standard'):
"""根据项目配置生成单张牌 PNG"""
scale_map = {'standard': 1, 'hd': 2, 'ultra-hd': 4}
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)
w = int(project.card_width * scale)
h = int(project.card_height * scale)
canvas = Image.new('RGBA', (w, h), (255, 255, 255, 255))
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))
design = get_effective_design(project, card_key)
# 获取卡片类型的所有图层
layers = CardLayer.objects.filter(
project=project,
card_key=card_key,
visible=True
).order_by('z_index')
# 1. 背景
draw_background(canvas, design, project, card_key)
# 渲染各图层
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)
# 2. 边框
draw_border(canvas, design)
return canvas
# 3. 主体内容
if card_key.startswith('joker-'):
which = card_key.split('-', 1)[1] # big / small
asset = None
for a in project.assets.filter(asset_type='joker', asset_key=which):
p = os.path.join('media', a.file_path) if a.file_path else None
asset = load_image_safe(p) if p else None
break
draw_joker(canvas, design, which, project, card_key, asset)
elif card_key in ('back', 'card-back'):
# 简化:背面同整体背景 + 一行文字
draw = ImageDraw.Draw(canvas)
fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True)
text = 'CARD BACK'
color = hex_to_rgba(design.get('border_color', '#333333'), 255)
bb = draw.textbbox((0, 0), text, font=fnt)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color)
else:
# 'suit-rank'
parts = card_key.split('-')
suit = parts[0]
rank = parts[1]
is_face = rank in ('J', 'Q', 'K')
if is_face:
asset = None
for a in project.assets.filter(asset_type='face_card', asset_key=card_key):
p = os.path.join('media', a.file_path) if a.file_path else None
asset = load_image_safe(p) if p else None
break
draw_face_card(canvas, design, suit, rank, project, card_key, asset)
else:
# A 写成 '1',但角标用 'A'
rk = 'A' if rank == '1' else rank
draw_number_card(canvas, design, suit, rank, project, card_key)
# 角标
draw_corner_index(canvas, design, suit, rk, project, card_key)
return canvas
# JQK 角标花色小rank 字母 J/Q/K
draw_corner_index(canvas, design, suit, rank, project, card_key)
return canvas

View File

@@ -2,6 +2,7 @@ from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from django.http import HttpResponse
from django.conf import settings
from ..projects.models import Project
from .utils import generate_card_png
import zipfile
@@ -9,12 +10,22 @@ import io
import os
def _all_card_keys(project):
"""生成所有 54 张牌的 key 列表"""
keys = []
for suit in ['spade', 'heart', 'club', 'diamond']:
for rank in ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']:
keys.append(f"{suit}-{rank}")
keys.append('joker-big')
keys.append('joker-small')
if project.export_include_back:
keys.append('back')
return keys
@api_view(['POST'])
def export_project(request, pk):
"""
批量导出整副牌为ZIP文件
请求体: { "resolution": "standard", "cards": "all" }
"""
"""批量导出整副牌为 ZIP"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
@@ -23,24 +34,13 @@ def export_project(request, pk):
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')
cards = _all_card_keys(project)
else:
cards = cards_filter if isinstance(cards_filter, list) else [cards_filter]
# 创建ZIP文件
zip_buffer = io.BytesIO()
failed = []
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for card_key in cards:
try:
@@ -50,31 +50,28 @@ def export_project(request, pk):
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)}")
failed.append({'card': card_key, 'error': str(e)})
continue
zip_buffer.seek(0)
# 保存到media目录
export_dir = os.path.join('media', 'export', str(project.id))
export_dir = os.path.join(settings.MEDIA_ROOT, '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())
download_url = f"{settings.MEDIA_URL}export/{project.id}/cards.zip"
return Response({
'download_url': f'/media/export/{project.id}/cards.zip',
'card_count': len(cards)
'download_url': download_url,
'card_count': len(cards),
'failed': failed,
})
@api_view(['GET'])
def export_single_card(request, pk, card_key):
"""
导出单张牌PNG
"""
"""导出单张牌 PNG"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
@@ -87,10 +84,8 @@ def export_single_card(request, pk, card_key):
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,94 @@
import os
from django.core.management.base import BaseCommand
from apps.projects.models import Project, Asset
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_sample_project()
self.stdout.write(self.style.SUCCESS('Initialization complete!'))
def create_templates(self):
"""创建示例模板"""
templates = [
{
'id': 'classic',
'name': '经典风格',
'description': '标准扑克牌设计,传统花色和字体',
'color_spade': '#000000',
'color_heart': '#E53935',
'color_club': '#000000',
'color_diamond': '#E53935',
'color_background': '#FFFFFF',
},
{
'id': 'modern',
'name': '现代简约',
'description': '扁平化设计,简洁线条',
'color_spade': '#333333',
'color_heart': '#E53935',
'color_club': '#333333',
'color_diamond': '#E53935',
'color_background': '#FAFAFA',
},
{
'id': 'cartoon',
'name': '卡通风格',
'description': 'Q版可爱人像圆润花色图案',
'color_spade': '#4A4A4A',
'color_heart': '#FF6B9D',
'color_club': '#4A4A4A',
'color_diamond': '#FF6B9D',
'color_background': '#FFF9E6',
},
{
'id': 'vintage',
'name': '复古风格',
'description': '复古色调和纹理,装饰性边框',
'color_spade': '#2C1810',
'color_heart': '#8B4513',
'color_club': '#2C1810',
'color_diamond': '#8B4513',
'color_background': '#F5DEB3',
},
]
for td in templates:
template, created = CardTemplate.objects.update_or_create(
id=td['id'],
defaults={
'name': td['name'],
'description': td['description'],
'color_spade': td['color_spade'],
'color_heart': td['color_heart'],
'color_club': td['color_club'],
'color_diamond': td['color_diamond'],
'color_background': td['color_background'],
'default_assets': td,
},
)
verb = 'created' if created else 'updated'
self.stdout.write(f' template {template.id} {verb}')
def create_sample_project(self):
"""创建示例项目:完整可玩的 54 张牌"""
project, created = Project.objects.update_or_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}')
self.stdout.write(self.style.SUCCESS(f'示例项目 ID: {project.id}'))

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2.14 on 2026-06-01 05:55
import apps.projects.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='project',
name='card_overrides',
field=models.JSONField(default=apps.projects.models.default_card_overrides),
),
migrations.AddField(
model_name='project',
name='design',
field=models.JSONField(default=apps.projects.models.default_design),
),
migrations.AddField(
model_name='project',
name='face_orientations',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='project',
name='number_layout',
field=models.JSONField(default=dict),
),
migrations.AlterField(
model_name='asset',
name='file_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
model_name='asset',
name='file_path',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='asset',
name='height',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='asset',
name='width',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -2,6 +2,40 @@ from django.db import models
import uuid
def default_design():
"""默认设计配置(整副牌共享)"""
return {
# 全局背景色(整副牌默认用这个,个别牌可覆盖)
'background_color': '#FFFFFF',
'background_image': None, # 整副牌背景图,相对 media 的路径
# 整副牌边框
'border_color': '#333333',
'border_width': 2,
# 4 个花色符号:可以上传图片,也可保持 None用字体符号
'suit_symbols': {
'spade': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#000000'},
'heart': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#E53935'},
'club': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#000000'},
'diamond': {'type': 'text', 'value': '', 'asset_id': None, 'color': '#E53935'},
},
# 数字牌角标和中心花色符号的大小(占牌面宽度比例)
'corner_size_ratio': 0.13,
'pip_size_ratio': 0.16,
# 字体
'font_family': 'Times New Roman',
'font_color': '#000000', # 角标数字颜色
# 角标布局微调(相对位置 0~1
'corner_offset': {'x': 0, 'y': 0},
}
def default_card_overrides():
"""每张牌可独立覆盖的项目级设置key=card_key, value 覆盖项)"""
return {
# 例如 'joker-big': { 'background_color': '#1B5E20' }
}
class Project(models.Model):
"""项目配置模型"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -9,6 +43,15 @@ class Project(models.Model):
template_id = models.CharField(max_length=50, default='classic')
card_width = models.IntegerField(default=750)
card_height = models.IntegerField(default=1050)
# 项目级设计配置
design = models.JSONField(default=default_design)
# 每张牌对项目级配置的覆盖
card_overrides = models.JSONField(default=default_card_overrides)
# 数字牌花色位置微调(相对 0~1
# { '1': [{'dx':0,'dy':0,'scale':1}, ...], '2': [...], ... }
number_layout = models.JSONField(default=dict)
# JQK 人物图的水平翻转(每张牌独立)
face_orientations = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -26,12 +69,12 @@ 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_type = models.CharField(max_length=20) # 'suit_symbol', 'face_card', 'joker', 'back', 'border', 'background'
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)
file_path = models.CharField(max_length=255, blank=True) # 相对于media目录
file_name = models.CharField(max_length=100, blank=True)
width = models.IntegerField(null=True, blank=True)
height = models.IntegerField(null=True, blank=True)
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
@@ -42,12 +85,12 @@ class Asset(models.Model):
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'
card_type = models.CharField(max_length=20) # 'number', 'face', 'joker', 'back'
card_key = models.CharField(max_length=30) # 'spade-A', 'heart-K', 'joker-big'
layer_name = models.CharField(max_length=50)
layer_type = models.CharField(max_length=20) # 'background', 'border', 'image', 'text'
layer_type = models.CharField(max_length=20) # 'background', 'border', 'pattern', 'image', 'text', 'symbol'
visible = models.BooleanField(default=True)
locked = models.BooleanField(default=False)
opacity = models.FloatField(default=1.0)

View File

@@ -6,7 +6,13 @@ from .models import Project, Asset, CardLayer
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = '__all__'
fields = [
'id', 'name', 'template_id',
'card_width', 'card_height',
'design', 'card_overrides', 'number_layout', 'face_orientations',
'export_resolution', 'export_include_back',
'created_at', 'updated_at',
]
class AssetSerializer(serializers.ModelSerializer):
@@ -14,7 +20,8 @@ class AssetSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = '__all__'
fields = ['id', 'asset_type', 'asset_key', 'file_path', 'file_name',
'file_url', 'width', 'height', 'uploaded_at']
def get_file_url(self, obj):
if obj.file_path:
@@ -37,4 +44,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = '__all__'
fields = [
'id', 'name', 'template_id',
'card_width', 'card_height',
'design', 'card_overrides', 'number_layout', 'face_orientations',
'export_resolution', 'export_include_back',
'assets', 'layers',
'created_at', 'updated_at',
]

View File

@@ -1,9 +1,13 @@
from django.urls import path
from .views import project_list, project_detail, asset_list, asset_detail
from .views import (
project_list, project_detail, project_save_design,
asset_list, asset_detail,
)
urlpatterns = [
path('', project_list, name='project-list'),
path('<str:pk>/', project_detail, name='project-detail'),
path('<str:pk>/design/', project_save_design, name='project-save-design'),
path('<str:project_pk>/assets/', asset_list, name='asset-list'),
path('<str:project_pk>/assets/<str:asset_pk>/', asset_detail, name='asset-detail'),
]

View File

@@ -18,7 +18,15 @@ def project_list(request):
return Response(serializer.data)
elif request.method == 'POST':
serializer = ProjectSerializer(data=request.data)
# 自动补默认 design/card_overrides/number_layout
data = dict(request.data or {})
if 'design' not in data:
data['design'] = Project._meta.get_field('design').default()
if 'card_overrides' not in data:
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()
serializer = ProjectSerializer(data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -38,7 +46,7 @@ def project_detail(request, pk):
return Response(serializer.data)
elif request.method == 'PUT':
serializer = ProjectSerializer(project, data=request.data)
serializer = ProjectSerializer(project, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
@@ -49,6 +57,27 @@ def project_detail(request, pk):
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
def project_save_design(request, pk):
"""整体保存项目设计design / card_overrides / number_layout"""
try:
project = Project.objects.get(pk=pk)
except Project.DoesNotExist:
return Response({'error': 'Project not found'}, status=status.HTTP_404_NOT_FOUND)
for field in ('design', 'card_overrides', 'number_layout', 'face_orientations'):
if field in request.data:
setattr(project, field, request.data[field])
project.save()
return Response({
'ok': True,
'design': project.design,
'card_overrides': project.card_overrides,
'number_layout': project.number_layout,
'face_orientations': project.face_orientations,
})
@api_view(['GET', 'POST'])
def asset_list(request, project_pk):
"""获取项目素材列表或上传新素材"""
@@ -59,7 +88,7 @@ def asset_list(request, project_pk):
if request.method == 'GET':
assets = project.assets.all()
serializer = AssetSerializer(assets, many=True)
serializer = AssetSerializer(assets, many=True, context={'request': request})
return Response(serializer.data)
elif request.method == 'POST':
@@ -75,19 +104,21 @@ def asset_list(request, project_pk):
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}"
# 避免重名覆盖:补上时间戳
from time import time
ts = int(time() * 1000)
file_name = f"{asset_key}_{ts}_{file.name}"
file_path = os.path.join(project_media_dir, file_name)
saved_path = default_storage.save(file_path, file)
# 获取图片尺寸
width, height = None, None
try:
img = Image.open(file)
width, height = img.size
except:
width, height = None, None
except Exception:
pass
# 创建Asset记录
asset = Asset.objects.create(
project=project,
asset_type=asset_type,
@@ -95,10 +126,10 @@ def asset_list(request, project_pk):
file_path=saved_path,
file_name=file_name,
width=width,
height=height
height=height,
)
serializer = AssetSerializer(asset)
serializer = AssetSerializer(asset, context={'request': request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -112,15 +143,16 @@ def asset_detail(request, project_pk, asset_pk):
return Response({'error': 'Asset not found'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
serializer = AssetSerializer(asset)
serializer = AssetSerializer(asset, context={'request': request})
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)
try:
os.remove(file_full_path)
except OSError:
pass
asset.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(status=status.HTTP_204_NO_CONTENT)