重构扑克牌设计系统:修复后端渲染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

36
.gitignore vendored Normal file
View File

@@ -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

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)
# 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)

View File

@@ -0,0 +1,113 @@
<template>
<div class="asset-panel">
<div class="header">
<h4>素材库</h4>
<button class="primary" @click="showUpload = true">+ 上传</button>
</div>
<!-- JQK 人物图 -->
<section v-if="faceCardAssets.length">
<h5>JQK 人物图</h5>
<div class="grid">
<div v-for="a in faceCardAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<!-- 大小王 -->
<section v-if="jokerAssets.length">
<h5>大小王</h5>
<div class="grid">
<div v-for="a in jokerAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key === 'big' ? '大王' : '小王' }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<!-- 背面 -->
<section v-if="backAssets.length">
<h5>背面图案</h5>
<div class="grid">
<div v-for="a in backAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<div v-if="!hasAny" class="empty">
<p>还没有素材</p>
<p class="hint">点击右上上传按钮添加 JQK 人物大小王图或背面</p>
<p class="hint">JQK 只需上传上半身图系统会自动生成中心对称的完整牌面</p>
</div>
<AssetUploadDialog
v-model="showUpload"
:project-id="store.project?.id"
@uploaded="onUploaded"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import axios from 'axios'
import { useProjectStore } from '@/stores/projectStore'
import AssetUploadDialog from '@/components/AssetUploadDialog.vue'
const store = useProjectStore()
const showUpload = ref(false)
const assets = computed(() => store.project?.assets || [])
const faceCardAssets = computed(() => assets.value.filter(a => a.asset_type === 'face_card'))
const jokerAssets = computed(() => assets.value.filter(a => a.asset_type === 'joker'))
const backAssets = computed(() => assets.value.filter(a => a.asset_type === 'back'))
const hasAny = computed(() => assets.value.length > 0)
async function onUploaded() {
await store.refreshAssets()
}
async function del(a) {
try {
await ElMessageBox.confirm(`确定删除「${a.asset_key}」素材?`, '删除', { type: 'warning' })
await axios.delete(`/api/projects/${store.project.id}/assets/${a.id}/`)
ElMessage.success('已删除')
await store.refreshAssets()
} catch (e) {
if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message)
}
}
</script>
<style scoped>
.asset-panel h4 { margin: 0; font-size: 14px; color: #ccc; }
.asset-panel h5 { margin: 0 0 8px 0; font-size: 12px; color: #aaa; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
button.primary { background: #e94560; color: #fff; border: none; border-radius: 3px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
section { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #16213e; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
.asset-tile { background: #16213e; border-radius: 4px; overflow: hidden; }
.asset-tile img { width: 100%; height: 70px; object-fit: contain; background: #fff; }
.asset-tile .meta { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; }
.asset-tile .key { font-size: 11px; color: #ccc; }
.mini { font-size: 10px; padding: 2px 6px; border: none; border-radius: 3px; cursor: pointer; }
.mini.ghost { background: transparent; color: #888; border: 1px solid #444; }
.mini.ghost:hover { color: #e94560; border-color: #e94560; }
.empty { text-align: center; padding: 20px 8px; color: #777; }
.empty p { margin: 4px 0; font-size: 12px; }
.empty .hint { font-size: 11px; color: #555; line-height: 1.4; }
</style>

View File

@@ -1,46 +1,67 @@
<template>
<el-dialog
v-model="dialogVisible"
title="上传素材"
width="500px"
:title="title"
width="480px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form :model="form" label-width="100px">
<el-form-item label="素材类型">
<el-select v-model="form.assetType" placeholder="选择素材类型">
<el-option label="花色图案" value="suit_symbol" />
<el-option label="JQK人像" value="face_card" />
<el-option label="大小王" value="joker" />
<el-form-item label="素材类型" required>
<el-select v-model="form.assetType" placeholder="选择类型" style="width: 100%;">
<el-option label="JQK 人物图" value="face_card" />
<el-option label="大王/小王图" value="joker" />
<el-option label="背面图案" value="back" />
<el-option label="花色符号图" value="suit_symbol" />
</el-select>
</el-form-item>
<el-form-item label="素材标识">
<el-input
<el-form-item label="素材标识" required>
<el-select
v-if="form.assetType === 'face_card' || form.assetType === 'joker' || form.assetType === 'suit_symbol'"
v-model="form.assetKey"
placeholder="spade, heart-J, big_joker"
:placeholder="keyPlaceholder"
style="width: 100%;"
>
<template v-if="form.assetType === 'face_card'">
<el-option-group v-for="s in suitOptions" :key="s" :label="suitLabel(s)">
<el-option v-for="r in faceRanks" :key="`${s}-${r}`" :label="`${suitSymbol(s)} ${r}`" :value="`${s}-${r}`" />
</el-option-group>
</template>
<template v-else-if="form.assetType === 'joker'">
<el-option label="大王 (BIG)" value="big" />
<el-option label="小王 (SMALL)" value="small" />
</template>
<template v-else-if="form.assetType === 'suit_symbol'">
<el-option v-for="s in suitOptions" :key="s" :label="`${suitSymbol(s)} ${suitLabel(s)}`" :value="s" />
</template>
</el-select>
<el-input
v-else
v-model="form.assetKey"
:placeholder="keyPlaceholder"
/>
</el-form-item>
<el-form-item label="选择文件">
<el-upload
ref="upload"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
:on-exceed="handleExceed"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或<em>点击上传</em>
<el-form-item label="选择文件" required>
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
@change="handleFileChange"
style="display: none;"
/>
<div class="upload-area" @click="fileInput?.click()" @drop.prevent="handleDrop" @dragover.prevent>
<div v-if="!form.preview" class="placeholder">
<div class="icon">+</div>
<div>点击或拖拽图片到此处</div>
<div class="hint">支持 PNG / JPG / SVG / WebP建议透明背景</div>
</div>
<template #tip>
<div class="el-upload__tip">
支持 PNG, JPG, SVG 格式
</div>
</template>
</el-upload>
<div v-else class="preview">
<img :src="form.preview" alt="preview" />
<div class="filename">{{ form.file?.name }}</div>
</div>
</div>
</el-form-item>
</el-form>
@@ -61,44 +82,59 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { uploadAsset } from '@/api/asset'
import axios from 'axios'
const props = defineProps({
modelValue: Boolean,
projectId: String
projectId: String,
})
const emit = defineEmits(['update:modelValue', 'uploaded'])
const emit = defineEmits(['update:modelValue', 'upload-success'])
const dialogVisible = ref(props.modelValue)
const upload = ref(null)
const fileInput = ref(null)
const uploading = ref(false)
const selectedFile = ref(null)
const form = ref({ assetType: 'face_card', assetKey: '', file: null, preview: null })
const form = ref({
assetType: 'suit_symbol',
assetKey: '',
file: null
const suitOptions = ['spade', 'heart', 'club', 'diamond']
const faceRanks = ['J', 'Q', 'K']
const suitSymbol = (s) => ({ spade: '♠', heart: '♥', club: '', diamond: '♦' })[s]
const suitLabel = (s) => ({ spade: '黑桃', heart: '红桃', club: '梅花', diamond: '方块' })[s]
const title = computed(() => '上传素材')
const keyPlaceholder = computed(() => {
switch (form.value.assetType) {
case 'face_card': return '选择 JQK 牌'
case 'joker': return '选择 大王/小王'
case 'suit_symbol': return '选择花色'
default: return '例如 back'
}
})
const canSubmit = computed(() => {
return form.value.assetKey && selectedFile.value
return form.value.assetType && form.value.assetKey && form.value.file
})
watch(() => props.modelValue, (val) => {
dialogVisible.value = val
})
watch(() => props.modelValue, (val) => { dialogVisible.value = val })
watch(dialogVisible, (val) => { emit('update:modelValue', val) })
watch(() => form.value.assetType, () => { form.value.assetKey = '' })
watch(dialogVisible, (val) => {
emit('update:modelValue', val)
})
function handleFileChange(file) {
selectedFile.value = file.raw
function handleFileChange(e) {
const f = e.target.files[0]
if (f) acceptFile(f)
}
function handleExceed() {
ElMessage.warning('只能上传一个文件')
function handleDrop(e) {
const f = e.dataTransfer.files[0]
if (f) acceptFile(f)
}
function acceptFile(f) {
if (!f.type.startsWith('image/')) {
ElMessage.warning('请上传图片文件')
return
}
form.value.file = f
const reader = new FileReader()
reader.onload = (ev) => { form.value.preview = ev.target.result }
reader.readAsDataURL(f)
}
async function handleSubmit() {
@@ -106,22 +142,20 @@ async function handleSubmit() {
ElMessage.warning('请填写完整信息')
return
}
const fd = new FormData()
fd.append('file', form.value.file)
fd.append('asset_type', form.value.assetType)
fd.append('asset_key', form.value.assetKey)
uploading.value = true
try {
uploading.value = true
await uploadAsset(
props.projectId,
selectedFile.value,
form.value.assetType,
form.value.assetKey
)
const r = await axios.post(`/api/projects/${props.projectId}/assets/`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
ElMessage.success('上传成功')
emit('upload-success')
emit('uploaded', r.data)
handleClose()
} catch (error) {
ElMessage.error('上传失败')
console.error(error)
} catch (e) {
ElMessage.error('上传失败: ' + (e.response?.data?.error || e.message))
} finally {
uploading.value = false
}
@@ -130,25 +164,28 @@ async function handleSubmit() {
function handleClose() {
dialogVisible.value = false
emit('update:modelValue', false)
// Reset form
form.value = {
assetType: 'suit_symbol',
assetKey: '',
file: null
}
selectedFile.value = null
form.value = { assetType: 'face_card', assetKey: '', file: null, preview: null }
}
</script>
<style scoped>
.el-upload__text {
color: #606266;
font-size: 14px;
}
.el-upload__tip {
color: #909399;
font-size: 12px;
margin-top: 8px;
.upload-area {
width: 100%;
min-height: 140px;
border: 2px dashed #555;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
}
.upload-area:hover { border-color: #e94560; background: #fff5f7; }
.placeholder { text-align: center; color: #888; }
.placeholder .icon { font-size: 36px; color: #aaa; }
.placeholder .hint { font-size: 12px; margin-top: 6px; }
.preview { text-align: center; }
.preview img { max-width: 200px; max-height: 120px; object-fit: contain; }
.preview .filename { font-size: 12px; color: #666; margin-top: 6px; }
</style>

View File

@@ -0,0 +1,218 @@
<template>
<div class="design-panel">
<!-- 背景色 -->
<section>
<h4>背景色</h4>
<p class="hint">整副牌默认用这个颜色个别牌可在下面单独覆盖</p>
<div class="row">
<input type="color" :value="design.background_color" @input="setBgColor($event.target.value)" />
<input type="text" :value="design.background_color" @change="setBgColor($event.target.value)" />
<button @click="uploadBg" class="mini">上传背景图</button>
</div>
<input ref="bgFileInput" type="file" accept="image/*" @change="onBgFile" hidden />
<div v-if="design.background_image" class="row">
<img :src="design.background_image" class="thumb" />
<button @click="clearBgImage" class="mini">清除</button>
</div>
</section>
<!-- 边框 -->
<section>
<h4>边框</h4>
<div class="row">
<label class="mini-label">颜色</label>
<input type="color" :value="design.border_color" @input="setBorder('border_color', $event.target.value)" />
<label class="mini-label">粗细</label>
<input type="number" min="0" max="40" :value="design.border_width"
@change="setBorder('border_width', parseInt($event.target.value) || 0)" />
</div>
</section>
<!-- 花色符号 -->
<section>
<h4>花色符号</h4>
<p class="hint">用系统字体显示一个 Unicode 符号想用图片可在右侧素材库上传后切换</p>
<div v-for="s in suits" :key="s" class="suit-row">
<div class="suit-label">{{ suitSymbol(s) }} {{ suitLabel(s) }}</div>
<input type="color" :value="design.suit_symbols?.[s]?.color || '#000000'"
@input="setSuit(s, 'color', $event.target.value)" />
<select :value="design.suit_symbols?.[s]?.type || 'text'"
@change="setSuit(s, 'type', $event.target.value)">
<option value="text">字体符号</option>
<option value="image" :disabled="!design.suit_symbols?.[s]?.asset_id">图片素材</option>
</select>
</div>
</section>
<!-- 尺寸 -->
<section>
<h4>大小比例</h4>
<label class="row">
<span>角标</span>
<input type="range" min="0.08" max="0.20" step="0.01"
:value="design.corner_size_ratio"
@input="setDesign('corner_size_ratio', parseFloat($event.target.value))" />
<span class="val">{{ Math.round(design.corner_size_ratio * 100) }}%</span>
</label>
<label class="row">
<span>中心花色</span>
<input type="range" min="0.08" max="0.24" step="0.01"
:value="design.pip_size_ratio"
@input="setDesign('pip_size_ratio', parseFloat($event.target.value))" />
<span class="val">{{ Math.round(design.pip_size_ratio * 100) }}%</span>
</label>
</section>
<!-- 单牌覆盖 -->
<section v-if="canHaveOverride">
<h4>本牌特殊设置</h4>
<p class="hint">只对当前选中的牌生效</p>
<div class="row">
<button @click="setOverrideBg" class="mini">设置独立背景色</button>
<button v-if="hasOverride" @click="clearOverride" class="mini ghost">清除</button>
</div>
<div v-if="override?.background_color" class="row">
<input type="color" :value="override.background_color"
@input="patchOverride('background_color', $event.target.value)" />
<input type="text" :value="override.background_color"
@change="patchOverride('background_color', $event.target.value)" />
</div>
</section>
<!-- 数字牌花色位置微调 -->
<section v-if="isNumberCard">
<h4>数字牌花色位置微调</h4>
<p class="hint">拖动滑块微调每个花色的位置占整张牌的比例0 = 默认</p>
<div class="rank-tabs">
<button v-for="r in [1,2,3,4,5,6,7,8,9,10]" :key="r"
:class="{ active: selectedRank === r }"
@click="selectedRank = r">{{ r === 1 ? 'A' : r }}</button>
</div>
<div v-for="(pos, i) in positions" :key="i" class="pip-row">
<div class="pip-label">#{{ i + 1 }}</div>
<label class="mini-label">dx</label>
<input type="range" min="-0.05" max="0.05" step="0.005"
:value="overrideFor(i).dx" @input="setOffset(i, 'dx', $event.target.value)" />
<label class="mini-label">dy</label>
<input type="range" min="-0.05" max="0.05" step="0.005"
:value="overrideFor(i).dy" @input="setOffset(i, 'dy', $event.target.value)" />
<label class="mini-label">缩放</label>
<input type="range" min="0.6" max="1.4" step="0.05"
:value="overrideFor(i).scale" @input="setOffset(i, 'scale', $event.target.value)" />
</div>
<button @click="resetLayout" class="mini ghost">重置本点数布局</button>
</section>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useProjectStore } from '@/stores/projectStore'
import { SUITS, SUIT_TEXT, LAYOUT_POSITIONS, isJoker } from '@/utils/cardLayout'
const store = useProjectStore()
const design = computed(() => store.effectiveDesign)
const override = computed(() => {
if (!store.project || !store.currentCard) return null
return (store.project.card_overrides || {})[store.currentCard] || null
})
const hasOverride = computed(() => !!override.value)
const suits = SUITS
const suitSymbol = (s) => SUIT_TEXT[s]
const suitLabel = (s) => ({ spade: '黑桃', heart: '红桃', club: '梅花', diamond: '方块' })[s]
const bgFileInput = ref(null)
function uploadBg() { bgFileInput.value?.click() }
function onBgFile(e) {
const f = e.target.files[0]
if (!f) return
const reader = new FileReader()
reader.onload = (ev) => {
// 这里只把 dataURL 临时存到 design正式应上传到后端
store.patchDesign('background_image', ev.target.result)
}
reader.readAsDataURL(f)
}
function clearBgImage() { store.patchDesign('background_image', null) }
function setBgColor(v) { store.patchDesign('background_color', v) }
function setBorder(path, v) { store.patchDesign(path, v) }
function setSuit(suit, path, v) { store.patchDesign(`suit_symbols.${suit}.${path}`, v) }
function setDesign(path, v) { store.patchDesign(path, v) }
const canHaveOverride = computed(() => {
return !isJoker(store.currentCard) && store.currentCard !== 'back'
})
function setOverrideBg() {
if (!canHaveOverride.value) return
store.patchCardOverride(store.currentCard, 'background_color', design.value.background_color)
}
function patchOverride(path, v) {
store.patchCardOverride(store.currentCard, path, v)
}
function clearOverride() {
store.clearCardOverride(store.currentCard)
}
const isNumberCard = computed(() => {
if (!store.currentCard || !store.currentCard.includes('-')) return false
const r = store.currentCard.split('-')[1]
return /^[0-9]+$/.test(r) || r === 'A'
})
const selectedRank = ref(1)
watch(() => store.currentCard, () => {
if (isNumberCard.value) {
const r = store.currentCard.split('-')[1]
selectedRank.value = r === 'A' ? 1 : parseInt(r)
}
})
const positions = computed(() => LAYOUT_POSITIONS[selectedRank.value] || LAYOUT_POSITIONS[1])
function overrideFor(i) {
const list = (store.project?.number_layout || {})[String(selectedRank.value)] || []
const o = list[i] || {}
return {
dx: Number(o.dx) || 0,
dy: Number(o.dy) || 0,
scale: Number(o.scale) || 1,
}
}
function setOffset(i, key, val) {
store.patchNumberLayout(String(selectedRank.value), i, { [key]: parseFloat(val) })
}
function resetLayout() {
store.resetNumberLayout(String(selectedRank.value))
}
</script>
<style scoped>
.design-panel section { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid #16213e; }
.design-panel h4 { margin: 0 0 6px 0; font-size: 13px; color: #ccc; }
.hint { font-size: 11px; color: #777; margin: 0 0 8px 0; line-height: 1.4; }
.row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.row span:first-child { font-size: 12px; color: #aaa; min-width: 56px; }
.row .val { font-size: 11px; color: #888; min-width: 32px; text-align: right; }
input[type="color"] { width: 28px; height: 24px; border: none; border-radius: 3px; cursor: pointer; background: transparent; }
input[type="text"] { flex: 1; background: #16213e; border: 1px solid #16213e; color: #eee; padding: 4px 6px; border-radius: 3px; font-size: 12px; }
input[type="number"] { width: 50px; background: #16213e; border: 1px solid #16213e; color: #eee; padding: 4px; border-radius: 3px; font-size: 12px; }
input[type="range"] { flex: 1; }
select { background: #16213e; border: 1px solid #16213e; color: #eee; padding: 4px; border-radius: 3px; font-size: 12px; }
.mini { background: #16213e; color: #aaa; padding: 4px 8px; font-size: 11px; border-radius: 3px; }
.mini.ghost { background: transparent; border: 1px solid #555; }
.mini-label { font-size: 11px; color: #888; min-width: 24px; }
.thumb { width: 60px; height: 30px; object-fit: cover; border-radius: 3px; }
.suit-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.suit-label { font-size: 12px; min-width: 70px; color: #ccc; }
.suit-row select { flex: 1; }
.rank-tabs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-bottom: 10px; }
.rank-tabs button { background: #16213e; color: #aaa; padding: 4px; font-size: 11px; border-radius: 3px; }
.rank-tabs button.active { background: #e94560; color: white; }
.pip-row { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; padding: 4px; background: #16213e; border-radius: 4px; }
.pip-label { font-size: 11px; min-width: 24px; color: #888; }
</style>

View File

@@ -0,0 +1,197 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import { DEFAULT_DESIGN, listAllCards } from '@/utils/cardLayout.js'
const API = '/api'
/**
* 项目状态管理:当前编辑的项目 + 当前选中的牌
*/
export const useProjectStore = defineStore('project', () => {
// 全部项目列表
const projects = ref([])
// 当前打开的项目
const project = ref(null)
// 当前选中的牌 key
const currentCard = ref('spade-A')
// 加载状态
const loading = ref(false)
const error = ref('')
// 全部 54 张牌 + 背面
const allCards = computed(() => listAllCards())
// 当前牌的有效 design合并项目级和单牌覆盖
const effectiveDesign = computed(() => {
if (!project.value) return DEFAULT_DESIGN
const base = JSON.parse(JSON.stringify(project.value.design || DEFAULT_DESIGN))
const ovr = (project.value.card_overrides || {})[currentCard.value] || {}
return { ...base, ...ovr }
})
async function fetchProjects() {
loading.value = true
error.value = ''
try {
const r = await axios.get(`${API}/projects/`)
projects.value = r.data || []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createProject(name, templateId = 'classic') {
const r = await axios.post(`${API}/projects/`, {
name,
template_id: templateId,
design: DEFAULT_DESIGN,
card_overrides: {},
number_layout: {},
})
projects.value.unshift(r.data)
return r.data
}
async function deleteProject(id) {
await axios.delete(`${API}/projects/${id}/`)
projects.value = projects.value.filter(p => p.id !== id)
}
async function loadProject(id) {
loading.value = true
error.value = ''
try {
const r = await axios.get(`${API}/projects/${id}/`)
// 兜底:如果老数据没有 design 字段,补上
if (!r.data.design) r.data.design = JSON.parse(JSON.stringify(DEFAULT_DESIGN))
if (!r.data.card_overrides) r.data.card_overrides = {}
if (!r.data.number_layout) r.data.number_layout = {}
if (!r.data.assets) r.data.assets = []
project.value = r.data
currentCard.value = 'spade-A'
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function saveName(name) {
if (!project.value) return
project.value.name = name
await axios.put(`${API}/projects/${project.value.id}/`, { name })
}
/**
* 整体保存设计design / card_overrides / number_layout / face_orientations
* 自动防抖
*/
let saveTimer = null
function scheduleSaveDesign() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(saveDesign, 500)
}
async function saveDesign() {
if (!project.value) return
try {
await axios.post(`${API}/projects/${project.value.id}/design/`, {
design: project.value.design,
card_overrides: project.value.card_overrides,
number_layout: project.value.number_layout,
face_orientations: project.value.face_orientations || {},
})
} catch (e) {
console.error('save design failed', e)
}
}
/**
* 修改 design 中的某个字段,自动保存
*/
function patchDesign(path, value) {
if (!project.value) return
setPath(project.value.design, path, value)
scheduleSaveDesign()
}
/**
* 修改某张牌对项目级设计的覆盖
*/
function patchCardOverride(cardKey, path, value) {
if (!project.value) return
if (!project.value.card_overrides[cardKey]) {
project.value.card_overrides[cardKey] = {}
}
setPath(project.value.card_overrides[cardKey], path, value)
scheduleSaveDesign()
}
function clearCardOverride(cardKey) {
if (!project.value) return
delete project.value.card_overrides[cardKey]
scheduleSaveDesign()
}
/**
* 修改数字牌花色位置(按 rank
* updates: { [index]: { dx, dy, scale } }
*/
function patchNumberLayout(rank, index, patch) {
if (!project.value) return
if (!project.value.number_layout[rank]) {
project.value.number_layout[rank] = []
}
project.value.number_layout[rank][index] = {
...(project.value.number_layout[rank][index] || {}),
...patch,
}
scheduleSaveDesign()
}
function resetNumberLayout(rank) {
if (!project.value) return
delete project.value.number_layout[rank]
scheduleSaveDesign()
}
function refreshAssets() {
return loadProject(project.value.id)
}
return {
projects,
project,
currentCard,
loading,
error,
allCards,
effectiveDesign,
fetchProjects,
createProject,
deleteProject,
loadProject,
saveName,
saveDesign,
scheduleSaveDesign,
patchDesign,
patchCardOverride,
clearCardOverride,
patchNumberLayout,
resetNumberLayout,
refreshAssets,
}
})
function setPath(obj, path, value) {
const parts = path.split('.')
let cur = obj
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] === undefined) cur[parts[i]] = {}
cur = cur[parts[i]]
}
cur[parts[parts.length - 1]] = value
}

View File

@@ -1,124 +1,88 @@
const LAYOUT_POSITIONS = {
1: [
{ x: 0.5, y: 0.5 }
],
2: [
{ x: 0.5, y: 0.25 },
{ x: 0.5, y: 0.75 }
],
3: [
{ x: 0.5, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.5, y: 0.8 }
],
4: [
{ x: 0.3, y: 0.25 },
{ x: 0.7, y: 0.25 },
{ x: 0.3, y: 0.75 },
{ x: 0.7, y: 0.75 }
],
5: [
{ x: 0.3, y: 0.2 },
{ x: 0.7, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.3, y: 0.8 },
{ x: 0.7, y: 0.8 }
],
6: [
{ x: 0.3, y: 0.2 },
{ x: 0.7, y: 0.2 },
{ x: 0.3, y: 0.5 },
{ x: 0.7, y: 0.5 },
{ x: 0.3, y: 0.8 },
{ x: 0.7, y: 0.8 }
],
7: [
{ x: 0.3, y: 0.15 },
{ x: 0.7, y: 0.15 },
{ x: 0.5, y: 0.35 },
{ x: 0.3, y: 0.55 },
{ x: 0.7, y: 0.55 },
{ x: 0.3, y: 0.85 },
{ x: 0.7, y: 0.85 }
],
8: [
{ x: 0.3, y: 0.15 },
{ x: 0.7, y: 0.15 },
{ x: 0.5, y: 0.35 },
{ x: 0.3, y: 0.55 },
{ x: 0.7, y: 0.55 },
{ x: 0.5, y: 0.65 },
{ x: 0.3, y: 0.85 },
{ x: 0.7, y: 0.85 }
],
9: [
{ x: 0.3, y: 0.15 },
{ x: 0.7, y: 0.15 },
{ x: 0.5, y: 0.35 },
{ x: 0.2, y: 0.5 },
{ x: 0.5, y: 0.5 },
{ x: 0.8, y: 0.5 },
{ x: 0.5, y: 0.65 },
{ x: 0.3, y: 0.85 },
{ x: 0.7, y: 0.85 }
],
10: [
{ x: 0.3, y: 0.15 },
{ x: 0.7, y: 0.15 },
{ x: 0.3, y: 0.35 },
{ x: 0.7, y: 0.35 },
{ x: 0.5, y: 0.5 },
{ x: 0.3, y: 0.65 },
{ x: 0.7, y: 0.65 },
{ x: 0.3, y: 0.85 },
{ x: 0.7, y: 0.85 }
]
/**
* 扑克牌布局数据 & 通用工具函数
*
* 这里和后端 apps/exports/utils.py 保持一致。
* 实际渲染在前端用 canvasdrawCard后端用 PILgenerate_card_png
*/
// 数字牌 1-10 的花色位置(相对坐标 0~1
export const LAYOUT_POSITIONS = {
1: [{ x: 0.50, y: 0.50 }],
2: [{ x: 0.50, y: 0.25 }, { x: 0.50, y: 0.75 }],
3: [{ x: 0.50, y: 0.20 }, { x: 0.50, y: 0.50 }, { x: 0.50, y: 0.80 }],
4: [{ x: 0.30, y: 0.25 }, { x: 0.70, y: 0.25 }, { x: 0.30, y: 0.75 }, { x: 0.70, y: 0.75 }],
5: [{ x: 0.30, y: 0.20 }, { x: 0.70, y: 0.20 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.80 }, { x: 0.70, y: 0.80 }],
6: [{ x: 0.30, y: 0.20 }, { x: 0.70, y: 0.20 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.30, y: 0.80 }, { x: 0.70, y: 0.80 }],
7: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.35 }, { x: 0.30, y: 0.55 }, { x: 0.70, y: 0.55 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
8: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.32 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.50, y: 0.68 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
9: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.30 }, { x: 0.22, y: 0.50 }, { x: 0.50, y: 0.50 }, { x: 0.78, y: 0.50 }, { x: 0.50, y: 0.70 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
10: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.30, y: 0.35 }, { x: 0.70, y: 0.35 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.65 }, { x: 0.70, y: 0.65 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
}
export function calculateSuitPositions(rank, cardWidth, cardHeight, symbolSize = 60) {
const positions = LAYOUT_POSITIONS[rank] || LAYOUT_POSITIONS[1]
return positions.map(pos => ({
x: pos.x * cardWidth - symbolSize / 2,
y: pos.y * cardHeight - symbolSize / 2,
width: symbolSize,
height: symbolSize
}))
export const SUIT_TEXT = {
spade: '♠',
heart: '♥',
club: '♣',
diamond: '♦',
}
export function getCornerPositions(cardWidth, cardHeight) {
return {
topLeft: { x: 50, y: 50 },
topRight: { x: cardWidth - 100, y: 50 },
bottomLeft: { x: 50, y: cardHeight - 100 },
bottomRight: { x: cardWidth - 100, y: cardHeight - 100 }
}
export const SUIT_LABELS = {
spade: '♠ 黑桃',
heart: '♥ 红桃',
club: '♣ 梅花',
diamond: '♦ 方块',
}
export function getSuitSymbol(suit) {
const symbols = {
spade: '',
heart: '',
club: '♣',
diamond: '♦'
}
return symbols[suit] || '♠'
export const SUIT_COLORS = {
spade: '#000000',
heart: '#E53935',
club: '#000000',
diamond: '#E53935',
}
export function getSuitColor(suit, templateColors) {
if (templateColors && templateColors[suit]) {
return templateColors[suit]
}
export const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
const colors = {
spade: '#000000',
heart: '#FF0000',
club: '#000000',
diamond: '#FF0000'
}
return colors[suit] || '#000000'
export const SUITS = ['spade', 'heart', 'club', 'diamond']
export const JOKERS = [
{ key: 'joker-big', label: '大王', defaultColor: '#1B5E20' },
{ key: 'joker-small', label: '小王', defaultColor: '#B71C1C' },
]
/**
* 合并项目级 design 与单牌覆盖
*/
export function getEffectiveDesign(project, cardKey) {
const base = JSON.parse(JSON.stringify(project?.design || {}))
const overrides = (project?.card_overrides || {})[cardKey] || {}
return { ...base, ...overrides }
}
/**
* 计算数字牌 (1-10) 实际的花色位置(绝对像素)
* - 默认按 LAYOUT_POSITIONS
* - 项目可保存 number_layout 做微调
*/
export function computeNumberPipPositions(rank, cardW, cardH, pipSize, numberLayout) {
const rankInt = parseInt(rank, 10) || 1
const defaults = LAYOUT_POSITIONS[rankInt] || LAYOUT_POSITIONS[1]
const userOverrides = (numberLayout || {})[String(rankInt)] || []
return defaults.map((p, i) => {
const o = userOverrides[i] || {}
const dx = Number(o.dx) || 0
const dy = Number(o.dy) || 0
const scale = Number(o.scale) || 1
return {
x: (p.x + dx) * cardW,
y: (p.y + dy) * cardH,
size: Math.max(20, pipSize * scale),
}
})
}
/**
* 解析一个花色 key 是否为红色系
*/
export function isRedSuit(suit) {
return suit === 'heart' || suit === 'diamond'
}
@@ -126,3 +90,49 @@ export function isRedSuit(suit) {
export function isBlackSuit(suit) {
return suit === 'spade' || suit === 'club'
}
export function isFace(rank) {
return rank === 'J' || rank === 'Q' || rank === 'K'
}
export function isJoker(cardKey) {
return typeof cardKey === 'string' && cardKey.startsWith('joker-')
}
export function isNumber(rank) {
return RANKS.indexOf(rank) >= 0 && !isFace(rank)
}
/**
* 列出全部 54 张牌 + 背面
*/
export function listAllCards() {
const out = []
for (const s of SUITS) {
for (const r of RANKS) {
out.push({ key: `${s}-${r}`, suit: s, rank: r, type: isFace(r) ? 'face' : 'number' })
}
}
out.push({ key: 'joker-big', suit: null, rank: null, type: 'joker' })
out.push({ key: 'joker-small', suit: null, rank: null, type: 'joker' })
out.push({ key: 'back', suit: null, rank: null, type: 'back' })
return out
}
export const DEFAULT_DESIGN = {
background_color: '#FFFFFF',
background_image: null,
border_color: '#333333',
border_width: 2,
suit_symbols: {
spade: { type: 'text', value: '♠', asset_id: null, color: '#000000' },
heart: { type: 'text', value: '♥', asset_id: null, color: '#E53935' },
club: { type: 'text', value: '♣', asset_id: null, color: '#000000' },
diamond: { type: 'text', value: '♦', asset_id: null, color: '#E53935' },
},
corner_size_ratio: 0.13,
pip_size_ratio: 0.16,
font_family: 'Times New Roman',
font_color: '#000000',
corner_offset: { x: 0, y: 0 },
}

View File

@@ -0,0 +1,402 @@
/**
* 扑克牌 Canvas 渲染器
*
* 用原生 Canvas2D API 在前端渲染牌面,与后端 PIL 渲染保持一致。
* 用户在编辑时实时看到的就是这个画面。
*/
import {
SUIT_TEXT,
SUIT_COLORS,
isRedSuit,
isFace,
isJoker,
computeNumberPipPositions,
getEffectiveDesign,
} from './cardLayout.js'
const CARD_W = 750
const CARD_H = 1050
// 图片缓存url -> HTMLImageElement
const imageCache = new Map()
function loadImage(url) {
return new Promise((resolve, reject) => {
if (!url) return resolve(null)
if (imageCache.has(url)) {
const cached = imageCache.get(url)
if (cached.complete && cached.naturalWidth) return resolve(cached)
// 还在加载中
cached.addEventListener('load', () => resolve(cached), { once: true })
cached.addEventListener('error', () => resolve(null), { once: true })
return
}
const img = new Image()
img.crossOrigin = 'anonymous'
imageCache.set(url, img)
img.addEventListener('load', () => resolve(img), { once: true })
img.addEventListener('error', () => resolve(null), { once: true })
img.src = url
})
}
async function preloadAll(project) {
const urls = new Set()
if (project.design?.background_image) urls.add(project.design.background_image)
for (const s of Object.keys(project.design?.suit_symbols || {})) {
const sym = project.design.suit_symbols[s]
if (sym?.type === 'image' && sym.asset_id) {
// 由后端 /media 提供
urls.add(`/media/${sym.value}`)
}
}
for (const a of project.assets || []) {
if (a.file_url) urls.add(a.file_url)
}
await Promise.all(Array.from(urls).map(u => loadImage(u)))
}
function assetByType(project, type, key) {
// 取最新一张匹配 (type, key) 的素材
if (!project.assets) return null
for (const a of project.assets) {
if (a.asset_type === type && a.asset_key === key) return a
}
return null
}
/* ---------- 绘图原语 ---------- */
function drawRoundedRect(ctx, x, y, w, h, r) {
if (r > w / 2) r = w / 2
if (r > h / 2) r = h / 2
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
function drawBackground(ctx, w, h, design) {
// 1. 底色
ctx.fillStyle = design.background_color || '#FFFFFF'
ctx.fillRect(0, 0, w, h)
// 2. 背景图(保持比例铺满)
if (design.background_image) {
const img = imageCache.get(design.background_image)
if (img && img.complete && img.naturalWidth) {
const ratio = Math.max(w / img.naturalWidth, h / img.naturalHeight)
const dw = img.naturalWidth * ratio
const dh = img.naturalHeight * ratio
ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh)
}
}
}
function drawBorder(ctx, w, h, design) {
const width = Number(design.border_width) || 0
if (width <= 0) return
ctx.save()
ctx.strokeStyle = design.border_color || '#333333'
ctx.lineWidth = width
const half = width / 2
drawRoundedRect(ctx, half, half, w - width, h - width, 16)
ctx.stroke()
ctx.restore()
}
function drawCornerIndex(ctx, w, h, suit, rank, design) {
const cornerRatio = Number(design.corner_size_ratio) || 0.13
const pad = Math.max(10, w * 0.045)
const color = (design.suit_symbols?.[suit]?.color)
|| (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade)
// rank 在上、suit 在下
const rankSize = Math.round(w * cornerRatio * 1.05)
const suitSize = Math.round(w * cornerRatio * 0.9)
const fontFamily = design.font_family || 'Times New Roman'
ctx.save()
ctx.fillStyle = color
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
// 左上
ctx.font = `bold ${rankSize}px ${fontFamily}, serif`
ctx.fillText(String(rank), pad, pad)
ctx.font = `${suitSize}px Arial, sans-serif`
const rankHeight = rankSize
ctx.fillText(SUIT_TEXT[suit], pad, pad + rankHeight + 2)
// 估算左上班块高度
const rankW = ctx.measureText(String(rank)).width
const suitW = ctx.measureText(SUIT_TEXT[suit]).width
const blockW = Math.max(rankW, suitW) + 8
const blockH = rankHeight + 2 + suitSize + 8
// 右下:把左上班块平移 + 旋转 180°再贴到右下
ctx.save()
ctx.translate(w - pad, h - pad)
ctx.rotate(Math.PI)
// 在新坐标系中绘制(左上)
ctx.fillStyle = color
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
ctx.font = `bold ${rankSize}px ${fontFamily}, serif`
ctx.fillText(String(rank), 0, 0)
ctx.font = `${suitSize}px Arial, sans-serif`
ctx.fillText(SUIT_TEXT[suit], 0, rankHeight + 2)
ctx.restore()
ctx.restore()
}
async function drawSuitSymbol(ctx, x, y, size, suit, design) {
const sym = design.suit_symbols?.[suit] || {}
if (sym.type === 'image' && sym.asset_id) {
const url = `/media/${sym.value}`
const img = imageCache.get(url)
if (img && img.complete && img.naturalWidth) {
ctx.drawImage(img, x - size / 2, y - size / 2, size, size)
return
}
}
// 文本符号
ctx.save()
ctx.fillStyle = sym.color || (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade)
ctx.font = `${Math.round(size)}px Arial, "Segoe UI Symbol", sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(SUIT_TEXT[suit], x, y)
ctx.restore()
}
async function drawNumberCardBody(ctx, w, h, suit, rank, design, project) {
const pipRatio = Number(design.pip_size_ratio) || 0.16
const pipSize = Math.max(40, w * pipRatio)
const positions = computeNumberPipPositions(rank, w, h, pipSize, project.number_layout)
for (const p of positions) {
await drawSuitSymbol(ctx, p.x, p.y, p.size, suit, design)
}
}
async function drawFaceCardBody(ctx, w, h, suit, rank, design, project) {
// 主体区域
const padX = w * 0.15
const padTop = h * 0.13
const padBot = h * 0.13
const bodyW = w - 2 * padX
const bodyH = h - padTop - padBot
const cardKey = `${suit}-${rank}`
const asset = assetByType(project, 'face_card', cardKey)
let img = null
if (asset?.file_url) {
img = imageCache.get(asset.file_url) || null
if (img) await loadImage(asset.file_url)
}
if (img && img.complete && img.naturalWidth) {
// 等比缩放 fill body
const ratio = img.naturalWidth / img.naturalHeight
const target = bodyW / bodyH
let drawW, drawH
if (ratio > target) {
drawW = bodyW
drawH = bodyW / ratio
} else {
drawH = bodyH
drawW = bodyH * ratio
}
const drawX = padX + (bodyW - drawW) / 2
const drawY = padTop + (bodyH - drawH) / 2
// 上下对称:先画上半,再把上半翻转贴到下半
const halfH = drawH / 2
ctx.save()
// 上半(先画完整图,用 clip 限定为上半)
ctx.beginPath()
ctx.rect(drawX, drawY, drawW, halfH)
ctx.clip()
ctx.drawImage(img, drawX, drawY, drawW, drawH)
ctx.restore()
// 下半:把原图翻转 180° 贴到下半区域
ctx.save()
ctx.translate(drawX + drawW, drawY + drawH)
ctx.rotate(Math.PI)
// 现在画完整图(经过旋转坐标系),让它 fill bodyW x halfH
const tmpW = drawW
const tmpH = halfH
// 由于旋转drawImage 时 x/y 是反向的;目标 = (0,0) 到 (drawW, halfH)
// 经过 180° 旋转,相当于在 (drawX, drawY) + (drawW, drawH) 处映射为 (-drawW, -drawH) 到 (0, 0)
// 所以让图片左上角对应 (0,0) 即可
ctx.drawImage(img, 0, 0, tmpW, tmpH * 2) // 0,0 -> drawW,halfH (after 180 rotation)
ctx.restore()
} else {
// 退化:绘制大花色 + 字母
const big = Math.round(h * 0.30)
const color = (design.suit_symbols?.[suit]?.color)
|| (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade)
ctx.save()
ctx.fillStyle = color
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `bold ${big}px ${design.font_family || 'Times New Roman'}, serif`
ctx.fillText(rank, w / 2, padTop + bodyH * 0.35)
ctx.font = `${big}px Arial, sans-serif`
ctx.fillText(SUIT_TEXT[suit], w / 2, padTop + bodyH * 0.65)
ctx.restore()
}
}
async function drawJokerBody(ctx, w, h, which, design, project) {
// 背景色
const bg = design.background_color
|| (which === 'big' ? '#1B5E20' : '#B71C1C')
ctx.fillStyle = bg
ctx.fillRect(0, 0, w, h)
const padX = w * 0.15
const padTop = h * 0.18
const padBot = h * 0.22
const bodyW = w - 2 * padX
const bodyH = h - padTop - padBot
const asset = assetByType(project, 'joker', which)
let img = null
if (asset?.file_url) {
img = imageCache.get(asset.file_url) || null
if (img) await loadImage(asset.file_url)
}
if (img && img.complete && img.naturalWidth) {
const ratio = img.naturalWidth / img.naturalHeight
const target = bodyW / bodyH
let drawW, drawH
if (ratio > target) {
drawW = bodyW; drawH = bodyW / ratio
} else {
drawH = bodyH; drawW = bodyH * ratio
}
ctx.drawImage(img, padX + (bodyW - drawW) / 2, padTop + (bodyH - drawH) / 2, drawW, drawH)
} else {
// 退化
const big = Math.round(h * 0.25)
ctx.save()
ctx.fillStyle = '#FFFFFF'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `bold ${big}px ${design.font_family || 'Times New Roman'}, serif`
ctx.fillText('JOKER', w / 2, h / 2)
ctx.restore()
}
// 角标
const label = which === 'big' ? 'BIG' : 'SMALL'
ctx.save()
ctx.fillStyle = '#FFFFFF'
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
const labelSize = Math.max(20, Math.round(w * 0.06))
const textSize = Math.max(16, Math.round(w * 0.045))
const pad = Math.max(10, w * 0.04)
ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif`
ctx.fillText('JOKER', pad, pad)
ctx.fillText(label, pad, pad + textSize + 4)
// 右下:旋转 180° 平移
ctx.translate(w - pad, h - pad)
ctx.rotate(Math.PI)
ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif`
ctx.fillText('JOKER', 0, 0)
ctx.fillText(label, 0, textSize + 4)
ctx.restore()
}
function drawBackSide(ctx, w, h, design) {
ctx.fillStyle = design.background_color || '#1A237E'
ctx.fillRect(0, 0, w, h)
ctx.save()
ctx.strokeStyle = design.border_color || '#FFFFFF'
ctx.lineWidth = 6
const m = w * 0.06
drawRoundedRect(ctx, m, m, w - 2 * m, h - 2 * m, 16)
ctx.stroke()
ctx.fillStyle = design.border_color || '#FFFFFF'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `bold ${Math.round(w * 0.1)}px ${design.font_family || 'Times New Roman'}, serif`
ctx.fillText('CARD BACK', w / 2, h / 2)
ctx.restore()
}
/* ---------- 公开 API ---------- */
/**
* 渲染单张牌到 canvas
* @param {HTMLCanvasElement} canvas
* @param {object} project
* @param {string} cardKey e.g. 'spade-A', 'joker-big', 'back'
* @returns {Promise<void>}
*/
export async function renderCard(canvas, project, cardKey) {
if (!canvas) return
// 适配高 DPI
const dpr = window.devicePixelRatio || 1
const w = CARD_W
const h = CARD_H
if (canvas.width !== w * dpr) {
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = '300px'
canvas.style.height = '420px'
}
const ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
// 预加载可能用到的图片
await preloadAll(project)
const design = getEffectiveDesign(project, cardKey)
if (isJoker(cardKey)) {
const which = cardKey.split('-', 2)[1]
await drawJokerBody(ctx, w, h, which, design, project)
drawBorder(ctx, w, h, design)
} else if (cardKey === 'back') {
drawBackSide(ctx, w, h, design)
} else {
const [suit, rank] = cardKey.split('-')
drawBackground(ctx, w, h, design)
drawBorder(ctx, w, h, design)
if (isFace(rank)) {
await drawFaceCardBody(ctx, w, h, suit, rank, design, project)
} else {
await drawNumberCardBody(ctx, w, h, suit, rank, design, project)
}
drawCornerIndex(ctx, w, h, suit, rank, design)
}
}
/**
* 渲染缩略图(小尺寸)到底部卡片列表
* 简化版:缩放渲染,不画边框等细节
*/
export async function renderThumbnail(canvas, project, cardKey, thumbW = 80, thumbH = 112) {
if (!canvas) return
if (canvas.width !== thumbW) {
canvas.width = thumbW
canvas.height = thumbH
}
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, thumbW, thumbH)
// 用单独的小画布渲染再缩放
const tmp = document.createElement('canvas')
tmp.width = CARD_W
tmp.height = CARD_H
await renderCard(tmp, project, cardKey)
ctx.drawImage(tmp, 0, 0, thumbW, thumbH)
}
export { CARD_W, CARD_H }

View File

@@ -1,364 +1,322 @@
<template>
<div style="min-height: 100vh; background: #1a1a2e; color: #eee; font-family: sans-serif;">
<header style="background: #16213e; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 20px;">
<button @click="$router.push('/')" style="background: #333; color: #aaa; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;"> 返回</button>
<input v-model="pname" @blur="saveName" style="background: transparent; border: none; color: white; font-size: 18px; font-weight: bold; outline: none; width: 300px;" placeholder="项目名称">
<div class="editor" v-if="store.project">
<header class="topbar">
<div class="left">
<button class="back" @click="router.push('/')"> 返回</button>
<input
v-model="projectName"
@blur="onNameBlur"
@keydown.enter="$event.target.blur()"
class="title"
placeholder="项目名称"
/>
</div>
<div style="display: flex; gap: 10px;">
<button @click="doExportAll" style="padding: 8px 20px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer;">导出全部</button>
<button @click="doExportSingle" style="padding: 8px 20px; background: #0f3460; color: #e94560; border: 1px solid #e94560; border-radius: 4px; cursor: pointer;">导出当前</button>
<div class="right">
<span class="save-status" v-if="saving">保存中</span>
<span class="save-status ok" v-else-if="lastSaved">已保存</span>
<button class="ghost" @click="exportSingle">导出当前</button>
<button class="primary" @click="exportAll">导出整副牌 (ZIP)</button>
</div>
</header>
<div style="display: flex; height: calc(100vh - 60px);">
<aside style="width: 260px; background: #0f3460; padding: 20px; overflow-y: auto;">
<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #888;">花色选择</h3>
<div style="display: flex; gap: 8px; margin-bottom: 25px;">
<button v-for="s in suits" :key="s"
@click="switchSuit(s)"
:style="{ padding: '8px 14px', border: 'none', borderRadius: '4px', cursor: 'pointer', background: currentSuit === s ? '#e94560' : '#16213e', color: currentSuit === s ? 'white' : '#aaa', fontWeight: currentSuit === s ? 'bold' : 'normal' }">
{{ suitLabels[s] }}
</button>
<div class="main">
<!-- 左侧牌面选择 + 编辑面板 -->
<aside class="left-pane">
<div class="card-tabs">
<button
v-for="g in cardGroups" :key="g.id"
:class="['tab', { active: currentGroup === g.id }]"
@click="currentGroup = g.id"
>{{ g.label }}</button>
</div>
<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #888;">图层管理</h3>
<div v-for="l in layers" :key="l.id"
@click="toggleLayer(l)"
:style="{ padding: '10px 12px', marginBottom: '5px', background: activeLayer === l.id ? '#16213e' : '#0f3460', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', borderLeft: activeLayer === l.id ? '3px solid #e94560' : '3px solid transparent', transition: 'all 0.2s' }">
<span style="width: 20px; text-align: center;">{{ l.visible ? '👁' : '—' }}</span>
<span style="font-size: 13px; flex: 1;">{{ l.name }}</span>
<span style="display: flex; flex-direction: column; gap: 2px;">
<button @click.stop="moveLayer(l, -1)" :disabled="l.zIndex <= 0"
:style="{ border: 'none', background: 'transparent', color: l.zIndex <= 0 ? '#333' : '#888', cursor: l.zIndex <= 0 ? 'default' : 'pointer', fontSize: '10px', padding: '0' }"></button>
<button @click.stop="moveLayer(l, 1)" :disabled="l.zIndex >= layers.length - 1"
:style="{ border: 'none', background: 'transparent', color: l.zIndex >= layers.length - 1 ? '#333' : '#888', cursor: l.zIndex >= layers.length - 1 ? 'default' : 'pointer', fontSize: '10px', padding: '0' }"></button>
</span>
</div>
<h3 style="margin: 25px 0 12px 0; font-size: 14px; color: #888;">属性编辑</h3>
<div v-if="activeLayer === 'bg'" style="display: flex; flex-direction: column; gap: 10px;">
<label style="font-size: 12px; color: #aaa;">背景颜色实时预览</label>
<input v-model="bgColor" type="color" @input="onBgColorChange" style="width: 100%; height: 32px; border: none; border-radius: 4px; cursor: pointer;">
<div style="border-top: 1px solid #333; margin: 4px 0;"></div>
<label style="font-size: 12px; color: #aaa;">上传背景图片</label>
<input ref="bgFileInput" type="file" accept="image/*" @change="onBgImageUpload" style="display: none;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button @click="triggerBgUpload" style="flex: 1; padding: 8px 12px; background: #16213e; color: #aaa; border: 1px solid #444; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap;">选择图片</button>
<button v-if="bgImageUrl" @click="clearBgImage" style="padding: 8px 12px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">清除</button>
<div class="card-list" v-if="currentGroup !== 'back'">
<div class="card-row">
<span class="row-label">花色</span>
<button v-for="s in suits" :key="s"
:class="['suit-btn', suitOfCurrent, { active: suitOfCurrent === s }]"
@click="switchSuit(s)">{{ suitSymbol(s) }}</button>
</div>
<div v-if="bgImageUrl" style="margin-top: 4px;">
<div style="font-size: 10px; color: #666; margin-bottom: 4px;">图片预览:</div>
<img :src="bgImageUrl" style="width: 100%; max-height: 100px; object-fit: contain; border-radius: 4px; border: 1px solid #333;">
<div class="rank-grid">
<button
v-for="c in cardsInGroup" :key="c.key"
:class="['card-cell', { active: store.currentCard === c.key }]"
@click="store.currentCard = c.key"
>{{ c.label }}</button>
</div>
</div>
<div v-if="activeLayer === 'border'" style="display: flex; flex-direction: column; gap: 8px;">
<label style="font-size: 12px; color: #aaa;">边框颜色实时</label>
<input v-model="borderColor" type="color" @input="onBorderColorChange" style="width: 100%; height: 32px; border: none; border-radius: 4px; cursor: pointer;">
<label style="font-size: 12px; color: #aaa;">边框粗细: {{ borderWidth }}px</label>
<input v-model="borderWidth" type="range" min="0" max="20" step="1" @input="onBorderChange" style="width: 100%; cursor: pointer;">
</div>
<div v-if="activeLayer === 'text'" style="display: flex; flex-direction: column; gap: 8px;">
<label style="font-size: 12px; color: #aaa;">字体大小实时: {{ textSize }}px</label>
<input v-model="textSize" type="range" min="12" max="120" step="2" @input="onTextSizeChange" style="width: 100%; cursor: pointer;">
</div>
<div v-if="!['bg','border','text','pattern'].includes(activeLayer)" style="color: #888; font-size: 12px; padding: 10px;">
选中画布上的对象查看属性
<div class="card-list" v-else>
<button class="card-cell" :class="{ active: store.currentCard === 'back' }"
@click="store.currentCard = 'back'">背面</button>
</div>
<div class="panel-title">设计编辑</div>
<DesignPanel />
</aside>
<main style="flex: 1; display: flex; justify-content: center; align-items: center; background: #1a1a2e; padding: 20px;">
<div style="background: #16213e; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.3);">
<canvas ref="canvasEl" id="main-canvas"></canvas>
<!-- 中间主画布 -->
<main class="canvas-pane">
<div class="canvas-wrap">
<canvas ref="mainCanvas" id="main-canvas"></canvas>
</div>
<div class="canvas-hint">
<span v-if="currentInfo">编辑中{{ currentInfo }}</span>
<span v-else>选择一张牌开始编辑</span>
</div>
</main>
<!-- 右侧素材管理 -->
<aside class="right-pane">
<AssetPanel />
</aside>
</div>
<footer style="background: #0f3460; padding: 10px 20px; display: flex; gap: 5px; overflow-x: auto;">
<div v-for="c in currentCards" :key="c.key"
@click="selectCard(c.key)"
:style="{ minWidth: '60px', height: '84px', background: currentCard === c.key ? '#e94560' : '#16213e', color: currentCard === c.key ? 'white' : '#aaa', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: '12px', fontWeight: currentCard === c.key ? 'bold' : 'normal', flexShrink: 0, transition: 'all 0.2s' }">
{{ c.label }}
<!-- 底部54 张缩略图 -->
<footer class="bottom-pane">
<div class="thumb-list">
<div
v-for="c in allThumbs" :key="c.key"
:class="['thumb', { active: store.currentCard === c.key }]"
@click="store.currentCard = c.key"
>
<canvas :ref="el => thumbRefs[c.key] = el" :width="60" :height="84"></canvas>
<div class="thumb-label">{{ c.label }}</div>
</div>
</div>
</footer>
</div>
<div v-else class="loading">加载项目中</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Canvas, FabricText, Rect, FabricImage } from 'fabric'
import { storeToRefs } from 'pinia'
import { useProjectStore } from '@/stores/projectStore'
import { renderCard, renderThumbnail } from '@/utils/cardRenderer'
import { SUITS, SUIT_TEXT, listAllCards, isJoker } from '@/utils/cardLayout'
import DesignPanel from '@/components/DesignPanel.vue'
import AssetPanel from '@/components/AssetPanel.vue'
import axios from 'axios'
const route = useRoute()
const router = useRouter()
const store = useProjectStore()
const { project, currentCard } = storeToRefs(store)
const canvasEl = ref(null)
const pname = ref('')
const currentSuit = ref('spade')
const currentCard = ref('spade-A')
const fabricCanvas = ref(null)
const activeLayer = ref('bg')
const projectId = computed(() => route.params.projectId)
const projectName = ref('')
const saving = ref(false)
const lastSaved = ref(false)
const currentGroup = ref('number')
const mainCanvas = ref(null)
const thumbRefs = ref({})
let saveWatchStop = null
const suits = ['spade', 'heart', 'club', 'diamond']
const suitLabels = { spade: '♠ 黑桃', heart: '♥ 红桃', club: '♣ 梅花', diamond: '♦ 方块' }
const ranks = ['A','2','3','4','5','6','7','8','9','10','J','Q','K']
const currentCards = computed(() => {
const cards = ranks.map(r => ({ key: `${currentSuit.value}-${r}`, label: getSymbol(currentSuit.value) + r }))
if (currentSuit.value === 'spade') {
cards.push({ key: 'joker-big', label: '大王' })
cards.push({ key: 'joker-small', label: '小王' })
}
return cards
const suits = SUITS
const suitSymbol = (s) => SUIT_TEXT[s]
const suitOfCurrent = computed(() => {
if (!currentCard.value || !currentCard.value.includes('-')) return 'spade'
const [s] = currentCard.value.split('-')
return s
})
const layers = ref([
{ id: 'bg', name: '背景层', visible: true, zIndex: 0, fillColor: '#ffffff', imageData: null },
{ id: 'border', name: '边框层', visible: true, zIndex: 1, strokeColor: '#333333', strokeWidth: 2 },
{ id: 'pattern', name: '图案层', visible: true, zIndex: 2 },
{ id: 'text', name: '文字层', visible: true, zIndex: 3, textSize: 32 }
])
const cardGroups = [
{ id: 'number', label: '数字牌' },
{ id: 'face', label: 'JQK' },
{ id: 'joker', label: '大小王' },
{ id: 'back', label: '背面' },
]
const bgColor = ref('#ffffff')
const bgImageUrl = ref(null)
const borderColor = ref('#333333')
const borderWidth = ref(2)
const textSize = ref(32)
const bgFileInput = ref(null)
function triggerBgUpload() {
bgFileInput.value?.click()
}
function onBgImageUpload(e) {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
bgImageUrl.value = ev.target.result
const l = layers.value.find(x => x.id === 'bg')
if (l) l.imageData = ev.target.result
drawCard()
const cardsInGroup = computed(() => {
const s = suitOfCurrent.value
if (currentGroup.value === 'number') {
return ['A','2','3','4','5','6','7','8','9','10'].map(r => ({
key: `${s}-${r}`,
label: `${SUIT_TEXT[s]}${r}`,
}))
}
reader.readAsDataURL(file)
}
function clearBgImage() {
bgImageUrl.value = null
const l = layers.value.find(x => x.id === 'bg')
if (l) l.imageData = null
drawCard()
}
function onBgColorChange() {
const l = layers.value.find(x => x.id === 'bg')
if (l) l.fillColor = bgColor.value
drawCard()
}
function toggleLayer(layer) {
activeLayer.value = layer.id
layer.visible = !layer.visible
syncLayerProps()
drawCard()
}
function syncLayerProps() {
const bgLayer = layers.value.find(x => x.id === 'bg')
if (bgLayer) {
bgColor.value = bgLayer.fillColor
bgImageUrl.value = bgLayer.imageData
if (currentGroup.value === 'face') {
return ['J','Q','K'].map(r => ({
key: `${s}-${r}`,
label: `${SUIT_TEXT[s]}${r}`,
}))
}
const bLayer = layers.value.find(x => x.id === 'border')
if (bLayer) {
borderColor.value = bLayer.strokeColor
borderWidth.value = bLayer.strokeWidth
if (currentGroup.value === 'joker') {
return [
{ key: 'joker-big', label: '大王' },
{ key: 'joker-small', label: '小王' },
]
}
const tLayer = layers.value.find(x => x.id === 'text')
if (tLayer) {
textSize.value = tLayer.textSize
}
}
function onBorderColorChange() {
const l = layers.value.find(x => x.id === 'border')
if (l) l.strokeColor = borderColor.value
drawCard()
}
function onBorderChange() {
const l = layers.value.find(x => x.id === 'border')
if (l) { l.strokeColor = borderColor.value; l.strokeWidth = parseInt(borderWidth.value) }
drawCard()
}
function onTextSizeChange() {
const l = layers.value.find(x => x.id === 'text')
if (l) l.textSize = parseInt(textSize.value)
drawCard()
}
function getSymbol(suit) {
return { spade: '♠', heart: '♥', club: '♣', diamond: '♦' }[suit] || ''
}
function isRed(suit) {
return suit === 'heart' || suit === 'diamond'
}
function moveLayer(layer, delta) {
const newZ = layer.zIndex + delta
const swapped = layers.value.find(l => l.zIndex === newZ)
if (swapped) {
swapped.zIndex = layer.zIndex
layer.zIndex = newZ
layers.value.sort((a, b) => a.zIndex - b.zIndex)
drawCard()
}
}
onMounted(async () => {
if (projectId.value) {
try {
const res = await axios.get(`/api/projects/${projectId.value}/`)
pname.value = res.data.name
} catch (e) {
console.error('Failed to load project:', e)
}
}
await nextTick()
initCanvas()
return []
})
function initCanvas() {
if (!canvasEl.value) return
fabricCanvas.value = new Canvas('main-canvas', {
width: 400,
height: 560,
backgroundColor: '#f5f5f5',
selection: true
})
fabricCanvas.value.on('selection:created', (e) => {
const obj = e.selected[0]
if (obj && obj.layerId) {
activeLayer.value = obj.layerId
syncLayerProps()
}
})
fabricCanvas.value.on('selection:updated', (e) => {
const obj = e.selected[0]
if (obj && obj.layerId) {
activeLayer.value = obj.layerId
syncLayerProps()
}
})
drawCard()
}
function drawCard() {
const c = fabricCanvas.value
if (!c) return
c.clear()
const sortedLayers = [...layers.value].sort((a, b) => a.zIndex - b.zIndex)
const suit = currentSuit.value
const rank = currentCard.value.split('-')[1] || 'A'
const sym = getSymbol(suit)
const color = isRed(suit) ? '#FF0000' : '#000000'
const cardW = 400
const cardH = 560
for (const layer of sortedLayers) {
if (!layer.visible) continue
if (layer.id === 'bg') {
if (layer.imageData) {
const bgImg = new Image()
bgImg.crossOrigin = 'anonymous'
bgImg.onload = () => {
const fImg = new FabricImage(bgImg, { left: 0, top: 0, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true })
fImg.scaleX = cardW / bgImg.width
fImg.scaleY = cardH / bgImg.height
fImg.layerId = 'bg'
c.insertAt(0, fImg)
c.renderAll()
}
bgImg.src = layer.imageData
}
const bgRect = new Rect({ left: 0, top: 0, width: cardW, height: cardH, fill: layer.imageData ? 'transparent' : (layer.fillColor || '#ffffff'), selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true, stroke: 'transparent', strokeWidth: 0 })
bgRect.layerId = 'bg'
c.add(bgRect)
}
if (layer.id === 'border') {
const frame = new Rect({ left: 10, top: 10, width: cardW - 20, height: cardH - 20, fill: 'transparent', stroke: layer.strokeColor || '#333', strokeWidth: layer.strokeWidth || 2, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true })
frame.layerId = 'border'
c.add(frame)
}
if (layer.id === 'pattern') {
const center = new FabricText(sym, {
left: cardW / 2, top: cardH / 2, fontSize: 80, fill: color, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true, originX: 'center', originY: 'center'
})
center.layerId = 'pattern'
c.add(center)
}
if (layer.id === 'text') {
const ts = layer.textSize || 32
const topLabel = new FabricText(`${rank}${sym}`, {
left: 18, top: 16, fontSize: ts, fill: color, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true
})
topLabel.layerId = 'text'
c.add(topLabel)
const bottomLabel = new FabricText(`${sym}${rank}`, {
left: cardW - 18, top: cardH - 16, fontSize: ts, fill: color, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true, originX: 'right', originY: 'bottom'
})
bottomLabel.layerId = 'text'
c.add(bottomLabel)
const allThumbs = computed(() => {
// 按 spade/heart/club/diamond × 13 + 大小王 + 背
const out = []
for (const s of SUITS) {
for (const r of ['A','2','3','4','5','6','7','8','9','10','J','Q','K']) {
out.push({ key: `${s}-${r}`, label: `${SUIT_TEXT[s]}${r}` })
}
}
out.push({ key: 'joker-big', label: '大' })
out.push({ key: 'joker-small', label: '小' })
out.push({ key: 'back', label: '背' })
return out
})
c.renderAll()
}
const currentInfo = computed(() => {
if (!currentCard.value) return ''
if (isJoker(currentCard.value)) {
return currentCard.value === 'joker-big' ? '大王' : '小王'
}
if (currentCard.value === 'back') return '牌面背面'
return currentCard.value.replace('-', ' ').toUpperCase()
})
function switchSuit(s) {
currentSuit.value = s
currentCard.value = `${s}-A`
drawCard()
const cur = store.currentCard
if (cur && cur.includes('-')) {
const [, r] = cur.split('-')
const isFace = r === 'J' || r === 'Q' || r === 'K'
if (isFace && currentGroup.value !== 'face') {
store.currentCard = `${s}-A`
} else {
store.currentCard = `${s}-${r}`
}
} else {
store.currentCard = `${s}-A`
}
}
function selectCard(key) {
currentCard.value = key
drawCard()
}
watch(currentCard, () => { drawAll() })
watch(() => project.value && project.value.assets, () => { drawAll() }, { deep: true })
watch(() => project.value && project.value.design, () => { drawAll() }, { deep: true })
watch(() => project.value && project.value.card_overrides, () => { drawAll() }, { deep: true })
watch(() => project.value && project.value.number_layout, () => { drawAll() }, { deep: true })
async function saveName() {
if (!projectId.value) return
try { await axios.put(`/api/projects/${projectId.value}/`, { name: pname.value }) } catch (e) {}
}
async function doExportAll() {
onMounted(async () => {
try {
const res = await axios.post(`/api/projects/${projectId.value}/export/`, { resolution: 'standard', cards: 'all' })
if (res.data.download_url) window.open(res.data.download_url, '_blank')
} catch (e) { alert('导出失败: ' + e.message) }
await store.loadProject(route.params.projectId)
projectName.value = project.value.name
await nextTick()
drawAll()
// 自动保存指示器
saveWatchStop = watch(
() => [project.value.design, project.value.card_overrides, project.value.number_layout],
() => { markSaving() },
{ deep: true }
)
} catch (e) {
alert('加载项目失败: ' + e.message)
}
})
onUnmounted(() => { if (saveWatchStop) saveWatchStop() })
function markSaving() {
saving.value = true
lastSaved.value = false
setTimeout(() => { saving.value = false; lastSaved.value = true }, 700)
setTimeout(() => { lastSaved.value = false }, 2500)
}
function doExportSingle() {
const url = `/api/projects/${projectId.value}/export/${currentCard.value}/?resolution=standard`
window.open(url, '_blank')
async function drawAll() {
if (!project.value) return
// 主画布
if (mainCanvas.value) {
await renderCard(mainCanvas.value, project.value, currentCard.value)
}
// 缩略图
for (const c of allThumbs.value) {
const cv = thumbRefs.value[c.key]
if (cv) await renderThumbnail(cv, project.value, c.key, 60, 84)
}
}
async function onNameBlur() {
if (project.value && projectName.value && projectName.value !== project.value.name) {
try { await store.saveName(projectName.value) } catch (e) { console.error(e) }
}
}
async function exportAll() {
try {
const r = await axios.post(`/api/projects/${project.value.id}/export/`,
{ resolution: 'standard', cards: 'all' },
{ responseType: 'blob' }
)
download(r.data, `${project.value.name || 'cards'}.zip`)
} catch (e) {
alert('导出失败: ' + e.message)
}
}
async function exportSingle() {
try {
const r = await axios.get(`/api/projects/${project.value.id}/export/${currentCard.value}/?resolution=standard`,
{ responseType: 'blob' }
)
download(r.data, `${currentCard.value}.png`)
} catch (e) {
alert('导出失败: ' + e.message)
}
}
function download(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename
document.body.appendChild(a); a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.editor { display: flex; flex-direction: column; height: 100vh; background: #1a1a2e; color: #eee; font-family: 'Microsoft YaHei', sans-serif; }
.loading { display: flex; height: 100vh; align-items: center; justify-content: center; color: #aaa; }
.topbar { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #16213e; border-bottom: 1px solid #0f3460; }
.topbar .left { display: flex; align-items: center; gap: 16px; }
.topbar .right { display: flex; align-items: center; gap: 10px; }
button { cursor: pointer; border: none; border-radius: 4px; padding: 6px 14px; font-size: 13px; }
button.primary { background: #e94560; color: #fff; }
button.primary:hover { background: #ff5577; }
button.ghost { background: transparent; border: 1px solid #e94560; color: #e94560; }
button.ghost:hover { background: rgba(233, 69, 96, 0.1); }
button.back { background: #333; color: #aaa; }
.title { background: transparent; border: none; color: white; font-size: 18px; font-weight: bold; outline: none; width: 280px; padding: 6px 8px; border-radius: 4px; }
.title:focus { background: rgba(255,255,255,0.06); }
.save-status { font-size: 12px; color: #888; margin-right: 4px; }
.save-status.ok { color: #66bb6a; }
.main { flex: 1; display: flex; min-height: 0; }
.left-pane { width: 280px; background: #0f3460; padding: 16px; overflow-y: auto; border-right: 1px solid #16213e; }
.right-pane { width: 320px; background: #0f3460; padding: 16px; overflow-y: auto; border-left: 1px solid #16213e; }
.canvas-pane { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; background: #1a1a2e; }
.card-tabs { display: flex; gap: 4px; margin-bottom: 12px; }
.card-tabs .tab { flex: 1; background: #16213e; color: #aaa; padding: 8px 6px; font-size: 12px; border-radius: 4px; }
.card-tabs .tab.active { background: #e94560; color: white; }
.card-list { background: #16213e; border-radius: 8px; padding: 10px; margin-bottom: 16px; }
.card-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.card-row .row-label { font-size: 12px; color: #aaa; width: 30px; }
.suit-btn { background: #0f3460; color: #aaa; padding: 6px 10px; font-size: 16px; border-radius: 4px; }
.suit-btn.active { background: #e94560; color: white; }
.rank-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
.card-cell { background: #0f3460; color: #ccc; padding: 6px 4px; font-size: 12px; border-radius: 4px; }
.card-cell.active { background: #e94560; color: white; }
.panel-title { font-size: 13px; color: #888; margin: 12px 0 8px 0; }
.canvas-wrap { background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.4); padding: 0; overflow: hidden; }
#main-canvas { display: block; }
.canvas-hint { margin-top: 12px; color: #aaa; font-size: 13px; }
.bottom-pane { background: #0f3460; padding: 8px; border-top: 1px solid #16213e; overflow-x: auto; }
.thumb-list { display: flex; gap: 6px; padding: 4px 0; }
.thumb { display: flex; flex-direction: column; align-items: center; cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s; }
.thumb:hover { background: #16213e; }
.thumb.active { background: #e94560; }
.thumb canvas { display: block; background: #fff; border-radius: 3px; }
.thumb-label { font-size: 10px; color: #aaa; margin-top: 2px; }
.thumb.active .thumb-label { color: white; }
</style>

View File

@@ -1,91 +1,71 @@
<template>
<div style="min-height: 100vh; background: #1a1a2e; color: #eee; font-family: sans-serif;">
<header style="background: #16213e; padding: 25px 40px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="margin: 0; font-size: 28px; color: #e94560;">扑克牌设计管理系统</h1>
<button @click="doCreate" style="padding: 12px 28px; background: #e94560; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; font-weight: bold;">
创建新项目
</button>
<div class="home">
<header class="topbar">
<h1>扑克牌设计管理系统</h1>
<button class="primary" @click="doCreate">+ 新建空白项目</button>
</header>
<main style="max-width: 1200px; margin: 0 auto; padding: 40px 20px;">
<h2 style="margin-bottom: 30px; font-size: 22px;">选择模板系列开始设计</h2>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 50px;">
<div v-for="t in templateList" :key="t.id"
@click="createFromTemplate(t.id)"
style="background: #0f3460; border-radius: 12px; padding: 25px; cursor: pointer; transition: all 0.3s; text-align: center;"
@mouseenter="$event.currentTarget.style.background = '#16213e'"
@mouseleave="$event.currentTarget.style.background = '#0f3460'">
<div style="font-size: 48px; margin-bottom: 12px;">{{ t.icon }}</div>
<h3 style="margin: 0 0 8px 0; font-size: 18px; color: #e94560;">{{ t.name }}</h3>
<p style="margin: 0; font-size: 13px; color: #aaa; line-height: 1.5;">{{ t.desc }}</p>
<main>
<section>
<h2>选择模板系列开始设计</h2>
<div class="template-grid">
<div v-for="t in templateList" :key="t.id"
class="template-card"
@click="createFromTemplate(t.id)">
<div class="icon">{{ t.icon }}</div>
<h3>{{ t.name }}</h3>
<p>{{ t.desc }}</p>
</div>
</div>
</div>
</section>
<div v-if="hasProjects" style="background: #0f3460; border-radius: 12px; padding: 25px;">
<h3 style="margin: 0 0 20px 0;">已有项目</h3>
<div v-for="p in projectList" :key="p.id"
style="display: flex; justify-content: space-between; align-items: center; padding: 15px; background: #16213e; border-radius: 8px; margin-bottom: 10px;">
<section v-if="hasProjects" class="project-list">
<h3>已有项目</h3>
<div v-for="p in projects" :key="p.id" class="project-row">
<div>
<strong>{{ p.name }}</strong>
<div style="font-size: 12px; color: #888; margin-top: 4px;">创建于: {{ formatDate(p.created_at) }}</div>
<div class="meta">
模板 {{ p.template_id }} · 创建于 {{ formatDate(p.created_at) }}
</div>
</div>
<div style="display: flex; gap: 8px;">
<button @click.stop="editProject(p.id)" style="padding: 6px 16px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer;">编辑</button>
<button @click.stop="removeProject(p.id)" style="padding: 6px 16px; background: #333; color: #aaa; border: none; border-radius: 4px; cursor: pointer;">删除</button>
<div class="row-actions">
<button class="primary" @click="editProject(p.id)">编辑</button>
<button class="danger" @click="removeProject(p.id)">删除</button>
</div>
</div>
</div>
</section>
<div v-if="loading" style="text-align: center; padding: 40px; color: #888;">加载中...</div>
<div v-if="loadError" style="text-align: center; padding: 40px; color: #e94560;">{{ loadError }}</div>
<div v-if="loading" class="muted">加载中</div>
<div v-if="error" class="error">{{ error }}</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { storeToRefs } from 'pinia'
import { useProjectStore } from '@/stores/projectStore.js'
const router = useRouter()
const projectList = ref([])
const loading = ref(false)
const loadError = ref('')
const store = useProjectStore()
const { projects, loading, error } = storeToRefs(store)
const templateList = [
{ id: 'classic', name: '经典风格', desc: '标准扑克牌设计,传统花色和字体', icon: '♠' },
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像圆润花色图案', icon: '★' },
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' }
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像圆润花色图案', icon: '★' },
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' },
]
const hasProjects = computed(() => projectList.value.length > 0)
const hasProjects = computed(() => projects.value.length > 0)
onMounted(() => {
loadProjects()
})
async function loadProjects() {
loading.value = true
loadError.value = ''
try {
const res = await axios.get('/api/projects/')
projectList.value = res.data || []
} catch (e) {
loadError.value = '无法连接后端服务: ' + e.message
} finally {
loading.value = false
}
}
onMounted(() => store.fetchProjects())
async function doCreate() {
try {
const res = await axios.post('/api/projects/', {
name: '新项目 ' + new Date().toLocaleDateString(),
template_id: 'classic'
})
router.push('/editor/' + res.data.id)
const p = await store.createProject('新项目 ' + new Date().toLocaleDateString())
router.push('/editor/' + p.id)
} catch (e) {
alert('创建失败: ' + e.message)
}
@@ -94,11 +74,8 @@ async function doCreate() {
async function createFromTemplate(tid) {
try {
const nm = templateList.find(t => t.id === tid)?.name || tid
const res = await axios.post('/api/projects/', {
name: nm + ' - 新项目',
template_id: tid
})
router.push('/editor/' + res.data.id)
const p = await store.createProject(nm + ' - ' + new Date().toLocaleDateString(), tid)
router.push('/editor/' + p.id)
} catch (e) {
alert('创建失败: ' + e.message)
}
@@ -109,10 +86,9 @@ function editProject(id) {
}
async function removeProject(id) {
if (!confirm('确定删除?')) return
if (!confirm('确定删除这个项目')) return
try {
await axios.delete('/api/projects/' + id + '/')
await loadProjects()
await store.deleteProject(id)
} catch (e) {
alert('删除失败: ' + e.message)
}
@@ -122,3 +98,29 @@ function formatDate(d) {
return new Date(d).toLocaleString('zh-CN')
}
</script>
<style scoped>
.home { min-height: 100vh; background: #1a1a2e; color: #eee; font-family: 'Microsoft YaHei', sans-serif; }
.topbar { background: #16213e; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
.topbar h1 { margin: 0; font-size: 24px; color: #e94560; }
main { max-width: 1200px; margin: 0 auto; padding: 30px 20px; }
section { margin-bottom: 36px; }
h2 { margin: 0 0 18px 0; font-size: 18px; color: #ccc; }
.template-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
.template-card { background: #0f3460; border-radius: 12px; padding: 22px; cursor: pointer; transition: all 0.2s; text-align: center; }
.template-card:hover { background: #16213e; transform: translateY(-2px); }
.template-card .icon { font-size: 44px; margin-bottom: 10px; color: #e94560; }
.template-card h3 { margin: 0 0 6px 0; font-size: 16px; }
.template-card p { margin: 0; font-size: 12px; color: #aaa; line-height: 1.5; }
.project-list { background: #0f3460; border-radius: 12px; padding: 22px; }
.project-row { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 8px; }
.project-row .meta { font-size: 12px; color: #888; margin-top: 4px; }
.row-actions { display: flex; gap: 8px; }
button { border: none; border-radius: 4px; cursor: pointer; padding: 6px 14px; font-size: 13px; }
button.primary { background: #e94560; color: white; }
button.primary:hover { background: #ff5577; }
button.danger { background: #333; color: #aaa; }
button.danger:hover { background: #444; color: #fff; }
.muted { color: #888; padding: 20px; text-align: center; }
.error { color: #e94560; padding: 12px; text-align: center; }
</style>

View File

@@ -1,67 +1,126 @@
<template>
<div style="padding: 20px; font-family: Arial, sans-serif;">
<h1>扑克牌设计管理系统 - 测试页面</h1>
<p>如果你能看到这个页面说明Vue正常工作</p>
<div class="test-page">
<header>
<h1>扑克牌设计管理系统 - 测试页面</h1>
<p>如果看到这个页面Vue 工作正常</p>
</header>
<div style="margin-top: 20px;">
<h2>API测试</h2>
<button @click="testAPI" style="padding: 10px 20px; cursor: pointer;">
测试API连接
</button>
<p v-if="apiResult">API返回: {{ apiResult }}</p>
<p v-if="apiError" style="color: red;">错误: {{ apiError }}</p>
</div>
<section>
<h2>系统健康检查</h2>
<div class="row">
<button @click="checkBackend">连接后端</button>
<button @click="checkProject">获取项目列表</button>
<button @click="checkCardLayout">测试牌面布局</button>
<button @click="testRenderer">测试 Canvas 渲染</button>
</div>
<pre v-if="apiResult" class="result">{{ apiResult }}</pre>
<p v-if="apiError" class="error">错误: {{ apiError }}</p>
</section>
<div style="margin-top: 20px;">
<h2>项目列表</h2>
<div v-if="loading">加载中...</div>
<div v-else-if="projects.length > 0">
<div v-for="project in projects" :key="project.id" style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
<h3>{{ project.name }}</h3>
<p>ID: {{ project.id }}</p>
<p>创建时间: {{ project.created_at }}</p>
<section v-if="rendered">
<h2>Canvas 渲染测试</h2>
<p>所有 4 个花色的 A + J + 大小王 + 背面</p>
<div class="card-row">
<div v-for="ck in testKeys" :key="ck" class="card-cell">
<canvas :ref="el => setRef(ck, el)" width="120" height="168"></canvas>
<div class="label">{{ ck }}</div>
</div>
</div>
<div v-else>没有项目</div>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import axios from 'axios'
import { LAYOUT_POSITIONS, SUITS, SUIT_TEXT } from '@/utils/cardLayout'
import { renderCard } from '@/utils/cardRenderer'
import { DEFAULT_DESIGN } from '@/utils/cardLayout'
const projects = ref([])
const loading = ref(false)
const apiResult = ref('')
const apiError = ref('')
const rendered = ref(false)
const canvasRefs = ref({})
const testKeys = [
'spade-A', 'heart-A', 'club-A', 'diamond-A',
'spade-7', 'spade-J', 'joker-big', 'joker-small', 'back',
]
onMounted(async () => {
await loadProjects()
function setRef(key, el) {
if (el) canvasRefs.value[key] = el
}
async function checkBackend() {
apiError.value = ''
try {
const r = await axios.get('/')
apiResult.value = JSON.stringify(r.data, null, 2)
} catch (e) {
apiError.value = e.message
}
}
async function checkProject() {
apiError.value = ''
try {
const r = await axios.get('/api/projects/')
apiResult.value = JSON.stringify(r.data, null, 2)
} catch (e) {
apiError.value = e.message
}
}
function checkCardLayout() {
apiError.value = ''
const out = {}
for (let i = 1; i <= 10; i++) {
out[i] = LAYOUT_POSITIONS[i]
}
apiResult.value = JSON.stringify(out, null, 2)
}
async function testRenderer() {
apiError.value = ''
try {
let projects = []
try {
const r = await axios.get('/api/projects/')
projects = r.data
} catch (e) { /* 离线模式 */ }
if (!projects.length) {
// 用默认 design 做演示
projects = [{ id: 'demo', name: 'demo', design: DEFAULT_DESIGN, card_overrides: {}, number_layout: {}, assets: [] }]
}
const proj = projects[0]
await nextTick()
for (const ck of testKeys) {
const cv = canvasRefs.value[ck]
if (cv) await renderCard(cv, proj, ck)
}
rendered.value = true
apiResult.value = '已渲染 ' + testKeys.length + ' 张牌'
} catch (e) {
apiError.value = e.message
}
}
onMounted(() => {
// 自动跑一次基础检查
})
async function loadProjects() {
loading.value = true
try {
const response = await axios.get('/api/projects/')
projects.value = response.data.value || response.data
apiResult.value = JSON.stringify(response.data, null, 2)
} catch (error) {
apiError.value = error.message
console.error('Failed to load projects:', error)
} finally {
loading.value = false
}
}
async function testAPI() {
try {
const response = await axios.get('/')
apiResult.value = JSON.stringify(response.data, null, 2)
apiError.value = ''
} catch (error) {
apiError.value = error.message
apiResult.value = ''
}
}
</script>
<style scoped>
.test-page { padding: 20px; max-width: 1200px; margin: 0 auto; font-family: 'Microsoft YaHei', sans-serif; }
header h1 { color: #e94560; margin: 0; }
section { margin: 24px 0; padding: 16px; background: #f5f5f5; border-radius: 8px; }
section h2 { margin: 0 0 12px 0; font-size: 16px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
button { background: #e94560; color: white; border: none; border-radius: 4px; padding: 8px 14px; cursor: pointer; }
button:hover { background: #ff5577; }
.result { background: #fff; padding: 12px; border-radius: 4px; overflow-x: auto; font-size: 12px; max-height: 400px; overflow-y: auto; }
.error { color: #e94560; }
.card-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.card-cell { background: white; padding: 4px; border-radius: 4px; }
.card-cell canvas { display: block; }
.card-cell .label { text-align: center; font-size: 10px; margin-top: 2px; }
</style>