From 2a36aa593cac94e0ecf418e131da9be84aa21866 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 1 Jun 2026 17:11:06 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=89=91=E5=85=8B=E7=89=8C?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E7=B3=BB=E7=BB=9F=EF=BC=9A=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=B8=B2=E6=9F=93bug=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E5=86=99=E5=89=8D=E7=AB=AF=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 36 ++ backend/apps/exports/utils.py | 543 +++++++++++----- backend/apps/exports/views.py | 53 +- backend/apps/projects/management/__init__.py | 0 .../projects/management/commands/__init__.py | 0 .../management/commands/init_system.py | 94 +++ ..._card_overrides_project_design_and_more.py | 54 ++ backend/apps/projects/models.py | 61 +- backend/apps/projects/serializers.py | 20 +- backend/apps/projects/urls.py | 6 +- backend/apps/projects/views.py | 62 +- frontend/src/components/AssetPanel.vue | 113 ++++ frontend/src/components/AssetUploadDialog.vue | 197 +++--- frontend/src/components/DesignPanel.vue | 218 +++++++ frontend/src/stores/projectStore.js | 197 ++++++ frontend/src/utils/cardLayout.js | 232 +++---- frontend/src/utils/cardRenderer.js | 402 ++++++++++++ frontend/src/views/Editor.vue | 588 ++++++++---------- frontend/src/views/Home.vue | 138 ++-- frontend/src/views/Test.vue | 165 +++-- 20 files changed, 2326 insertions(+), 853 deletions(-) create mode 100644 .gitignore create mode 100644 backend/apps/projects/management/__init__.py create mode 100644 backend/apps/projects/management/commands/__init__.py create mode 100644 backend/apps/projects/management/commands/init_system.py create mode 100644 backend/apps/projects/migrations/0002_project_card_overrides_project_design_and_more.py create mode 100644 frontend/src/components/AssetPanel.vue create mode 100644 frontend/src/components/DesignPanel.vue create mode 100644 frontend/src/stores/projectStore.js create mode 100644 frontend/src/utils/cardRenderer.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..110fc84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*.pyc + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# Database +*.sqlite3 +*.db + +# Media uploads +media/upload/ +media/export/ +!media/.gitkeep + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment +.env +.env.local diff --git a/backend/apps/exports/utils.py b/backend/apps/exports/utils.py index ca6868d..eed68d2 100644 --- a/backend/apps/exports/utils.py +++ b/backend/apps/exports/utils.py @@ -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 \ No newline at end of file + # 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 diff --git a/backend/apps/exports/views.py b/backend/apps/exports/views.py index dd65c33..40e0956 100644 --- a/backend/apps/exports/views.py +++ b/backend/apps/exports/views.py @@ -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) diff --git a/backend/apps/projects/management/__init__.py b/backend/apps/projects/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/projects/management/commands/__init__.py b/backend/apps/projects/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/projects/management/commands/init_system.py b/backend/apps/projects/management/commands/init_system.py new file mode 100644 index 0000000..70a6572 --- /dev/null +++ b/backend/apps/projects/management/commands/init_system.py @@ -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}')) diff --git a/backend/apps/projects/migrations/0002_project_card_overrides_project_design_and_more.py b/backend/apps/projects/migrations/0002_project_card_overrides_project_design_and_more.py new file mode 100644 index 0000000..c0ad891 --- /dev/null +++ b/backend/apps/projects/migrations/0002_project_card_overrides_project_design_and_more.py @@ -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), + ), + ] diff --git a/backend/apps/projects/models.py b/backend/apps/projects/models.py index 5cb0beb..4aa393e 100644 --- a/backend/apps/projects/models.py +++ b/backend/apps/projects/models.py @@ -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) diff --git a/backend/apps/projects/serializers.py b/backend/apps/projects/serializers.py index 4e36931..a801cea 100644 --- a/backend/apps/projects/serializers.py +++ b/backend/apps/projects/serializers.py @@ -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__' \ No newline at end of file + 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', + ] diff --git a/backend/apps/projects/urls.py b/backend/apps/projects/urls.py index 69cde9f..482446f 100644 --- a/backend/apps/projects/urls.py +++ b/backend/apps/projects/urls.py @@ -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('/', project_detail, name='project-detail'), + path('/design/', project_save_design, name='project-save-design'), path('/assets/', asset_list, name='asset-list'), path('/assets//', asset_detail, name='asset-detail'), ] diff --git a/backend/apps/projects/views.py b/backend/apps/projects/views.py index 7c36199..73f5cb3 100644 --- a/backend/apps/projects/views.py +++ b/backend/apps/projects/views.py @@ -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) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/frontend/src/components/AssetPanel.vue b/frontend/src/components/AssetPanel.vue new file mode 100644 index 0000000..d5a7685 --- /dev/null +++ b/frontend/src/components/AssetPanel.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/frontend/src/components/AssetUploadDialog.vue b/frontend/src/components/AssetUploadDialog.vue index d0ede74..2220876 100644 --- a/frontend/src/components/AssetUploadDialog.vue +++ b/frontend/src/components/AssetUploadDialog.vue @@ -1,46 +1,67 @@