重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
backend/apps/projects/management/__init__.py
Normal file
0
backend/apps/projects/management/__init__.py
Normal file
94
backend/apps/projects/management/commands/init_system.py
Normal file
94
backend/apps/projects/management/commands/init_system.py
Normal 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}'))
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user