重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
|
||||||
@@ -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 io
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# 标准扑克牌花色符号(用作默认渲染)
|
||||||
|
SUIT_TEXT = {
|
||||||
|
'spade': '♠',
|
||||||
|
'heart': '♥',
|
||||||
|
'club': '♣',
|
||||||
|
'diamond': '♦',
|
||||||
|
}
|
||||||
|
RED_SUITS = {'heart', 'diamond'}
|
||||||
|
BLACK_SUITS = {'spade', 'club'}
|
||||||
|
|
||||||
def load_image(file_path, scale=1):
|
# 数字牌点数 1-10 的花色位置(相对坐标 0~1)
|
||||||
"""加载图片并应用缩放"""
|
LAYOUT_POSITIONS = {
|
||||||
img = Image.open(file_path).convert('RGBA')
|
1: [(0.50, 0.50)],
|
||||||
|
2: [(0.50, 0.25), (0.50, 0.75)],
|
||||||
if scale > 1:
|
3: [(0.50, 0.20), (0.50, 0.50), (0.50, 0.80)],
|
||||||
new_size = (int(img.width * scale), int(img.height * scale))
|
4: [(0.30, 0.25), (0.70, 0.25), (0.30, 0.75), (0.70, 0.75)],
|
||||||
img = img.resize(new_size, Image.LANCZOS)
|
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)],
|
||||||
return img
|
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):
|
def hex_to_rgba(hex_color, alpha=255):
|
||||||
"""
|
"""将 #RRGGBB 转为 (r, g, b, a)"""
|
||||||
生成JQK中心对称图案
|
if not hex_color:
|
||||||
输入:原始图片路径
|
return (255, 255, 255, alpha)
|
||||||
输出:中心对称的图像数组(上半部分、下半部分)
|
h = hex_color.lstrip('#')
|
||||||
"""
|
if len(h) == 3:
|
||||||
original = load_image(original_image_path, scale)
|
h = ''.join(c * 2 for c in h)
|
||||||
width, height = original.size
|
|
||||||
half_height = height // 2
|
|
||||||
|
|
||||||
# 创建上半部分
|
|
||||||
top_half = original.crop((0, 0, width, half_height))
|
|
||||||
|
|
||||||
# 创建下半部分并翻转
|
|
||||||
bottom_half = original.crop((0, half_height, width, height))
|
|
||||||
bottom_half = bottom_half.transpose(Image.FLIP_TOP_BOTTOM)
|
|
||||||
|
|
||||||
return top_half, bottom_half
|
|
||||||
|
|
||||||
|
|
||||||
def render_background(canvas, layer, scale):
|
|
||||||
"""渲染背景层"""
|
|
||||||
if layer.properties:
|
|
||||||
properties = layer.properties
|
|
||||||
width = canvas.size[0]
|
|
||||||
height = canvas.size[1]
|
|
||||||
|
|
||||||
# 解析color(如 '#FF0000' 或 'rgb(255,0,0)')
|
|
||||||
bg_color = properties.get('color', '#FFFFFF')
|
|
||||||
|
|
||||||
# 创建背景矩形
|
|
||||||
draw = ImageDraw.Draw(canvas, 'RGBA')
|
|
||||||
draw.rectangle(((0, 0), (width, height)), fill=bg_color + 'FF')
|
|
||||||
|
|
||||||
# 如果有纹理或图案路径
|
|
||||||
texture_path = properties.get('texture_path')
|
|
||||||
if texture_path and os.path.exists(texture_path):
|
|
||||||
texture = load_image(texture_path, scale)
|
|
||||||
bg_height = height // 4
|
|
||||||
for y in range(0, height, bg_height):
|
|
||||||
canvas.paste(texture, (0, y), texture)
|
|
||||||
|
|
||||||
|
|
||||||
def render_image_layer(canvas, project, layer, scale):
|
|
||||||
"""渲染图片层(人像、花色等)"""
|
|
||||||
if not layer.file_ref or not layer.file_ref.file_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
asset_path = os.path.join(project.media_root, layer.file_ref.file_path)
|
|
||||||
|
|
||||||
if not os.path.exists(asset_path):
|
|
||||||
return
|
|
||||||
|
|
||||||
image = load_image(asset_path, scale)
|
|
||||||
|
|
||||||
# 获取位置信息
|
|
||||||
properties = layer.properties or {}
|
|
||||||
x = properties.get('x', 0)
|
|
||||||
y = properties.get('y', 0)
|
|
||||||
width = properties.get('width', image.size[0])
|
|
||||||
height = properties.get('height', image.size[1])
|
|
||||||
|
|
||||||
# 计算实际坐标
|
|
||||||
canvas_width, canvas_height = canvas.size
|
|
||||||
actual_x = (x / project.card_width) * canvas_width
|
|
||||||
actual_y = (y / project.card_height) * canvas_height
|
|
||||||
|
|
||||||
# 计算实际尺寸
|
|
||||||
actual_w = (width / project.card_width) * canvas_width
|
|
||||||
actual_h = (height / project.card_height) * canvas_height
|
|
||||||
|
|
||||||
# 裁剪图片
|
|
||||||
cropped = image.copy()
|
|
||||||
cropped.thumbnail((actual_w, actual_h), Image.LANCZOS)
|
|
||||||
|
|
||||||
# 计算居中位置
|
|
||||||
paste_x = actual_x + (actual_w - cropped.size[0]) / 2
|
|
||||||
paste_y = actual_y + (actual_h - cropped.size[1]) / 2
|
|
||||||
|
|
||||||
canvas.paste(cropped, (int(paste_x), int(paste_y)), cropped)
|
|
||||||
|
|
||||||
|
|
||||||
def render_text_layer(canvas, layer, scale):
|
|
||||||
"""渲染文字层"""
|
|
||||||
properties = layer.properties or {}
|
|
||||||
|
|
||||||
draw = ImageDraw.Draw(canvas, 'RGBA')
|
|
||||||
|
|
||||||
text = properties.get('text', '')
|
|
||||||
x = properties.get('x', 0)
|
|
||||||
y = properties.get('y', 0)
|
|
||||||
|
|
||||||
# 解析字体和颜色
|
|
||||||
font = properties.get('font', None)
|
|
||||||
if font and isinstance(font, dict):
|
|
||||||
font_size = int(font.get('size', 24) * scale)
|
|
||||||
font_path = font.get('path')
|
|
||||||
|
|
||||||
from PIL import ImageFont
|
|
||||||
if font_path and os.path.exists(font_path):
|
|
||||||
try:
|
try:
|
||||||
custom_font = ImageFont.truetype(font_path, font_size)
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
|
||||||
except:
|
except Exception:
|
||||||
custom_font = None
|
return (255, 255, 255, alpha)
|
||||||
else:
|
|
||||||
custom_font = None
|
|
||||||
else:
|
|
||||||
from PIL import ImageFont
|
|
||||||
custom_font = ImageFont.load_default()
|
|
||||||
|
|
||||||
# 转换颜色
|
|
||||||
color = properties.get('color', '#000000')
|
|
||||||
if color.startswith('#'):
|
|
||||||
r = int(color[1:3], 16)
|
|
||||||
g = int(color[3:5], 16)
|
|
||||||
b = int(color[5:7], 16)
|
|
||||||
fill = (r, g, b, 255)
|
|
||||||
else:
|
|
||||||
fill = (0, 0, 0, 255)
|
|
||||||
|
|
||||||
# 计算实际坐标和尺寸
|
|
||||||
canvas_width, canvas_height = canvas.size
|
|
||||||
actual_x = (x / project.card_width) * canvas_width
|
|
||||||
actual_y = (y / project.card_height) * canvas_height
|
|
||||||
|
|
||||||
bbox = draw.textbbox((0, 0), text, font=custom_font)
|
|
||||||
text_width = bbox[2] - bbox[0]
|
|
||||||
text_height = bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
# 居中计算
|
|
||||||
paste_x = actual_x + (canvas_width * project.card_width * 0.3 - text_width) / 2
|
|
||||||
paste_y = actual_y + (canvas_height * project.card_height * 0.5 - text_height) / 2
|
|
||||||
|
|
||||||
draw.text((int(paste_x), int(paste_y)), text, font=custom_font, fill=fill)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_card_png(project, card_key, resolution='standard', scale_map={ 'standard': 1, 'hd': 2, 'ultra-hd': 4 }):
|
def is_red(suit):
|
||||||
|
return suit in RED_SUITS
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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 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:
|
||||||
|
# 走 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 + 小花色 组成
|
||||||
"""
|
"""
|
||||||
生成单张牌的PNG图片
|
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)
|
||||||
|
|
||||||
Args:
|
# 角标和中心花色大小(占牌面宽度比例)
|
||||||
project: Project对象
|
corner_ratio = float(design.get('corner_size_ratio', 0.13) or 0.13)
|
||||||
card_key: 牌面key(如'hearts-A', 'spades-K', 'joker-big')
|
pip_ratio = float(design.get('pip_size_ratio', 0.16) or 0.16)
|
||||||
resolution: 分辨率(standard/hd/ultra-hd)
|
|
||||||
scale_map: 分辨率对应的缩放比例
|
|
||||||
|
|
||||||
Returns:
|
corner_size = max(20, int(w * corner_ratio))
|
||||||
Image对象
|
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:
|
||||||
|
dx = dy = 0
|
||||||
|
scale = 1.0
|
||||||
|
positions.append((fx + dx, fy + dy, scale))
|
||||||
|
|
||||||
|
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 draw_face_card(canvas, design, suit, rank, project, card_key, asset):
|
||||||
|
"""绘制 JQK 人物图:上半 + 上下翻转的下半,形成中心对称
|
||||||
|
资源找不到时退化为一个大花色符号 + 字母
|
||||||
"""
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 取上半部分 + 上下翻转的下半部分(取上半 = 下半 = 整个图按上下中线翻转)
|
||||||
|
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)
|
scale = scale_map.get(resolution, 1)
|
||||||
|
|
||||||
# 创建基础画布
|
w = int(project.card_width * scale)
|
||||||
# 牌面坐标系
|
h = int(project.card_height * scale)
|
||||||
x_offset = int(50 * scale)
|
canvas = Image.new('RGBA', (w, h), (255, 255, 255, 255))
|
||||||
y_offset = int(50 * scale)
|
|
||||||
draw_width = int((project.card_width - 100) * scale)
|
|
||||||
draw_height = int((project.card_height - 100) * scale)
|
|
||||||
|
|
||||||
canvas = Image.new('RGBA', (draw_width, draw_height))
|
design = get_effective_design(project, card_key)
|
||||||
draw = ImageDraw.Draw(canvas, 'RGBA')
|
|
||||||
draw.rectangle(((0, 0), (draw_width, draw_height)), fill=(255, 255, 255, 255))
|
|
||||||
|
|
||||||
# 获取卡片类型的所有图层
|
# 1. 背景
|
||||||
layers = CardLayer.objects.filter(
|
draw_background(canvas, design, project, card_key)
|
||||||
project=project,
|
|
||||||
card_key=card_key,
|
|
||||||
visible=True
|
|
||||||
).order_by('z_index')
|
|
||||||
|
|
||||||
# 渲染各图层
|
# 2. 边框
|
||||||
for layer in layers:
|
draw_border(canvas, design)
|
||||||
layer_type = layer.layer_type
|
|
||||||
if layer_type == 'background':
|
# 3. 主体内容
|
||||||
render_background(canvas, layer, scale)
|
if card_key.startswith('joker-'):
|
||||||
elif layer_type == 'image':
|
which = card_key.split('-', 1)[1] # big / small
|
||||||
render_image_layer(canvas, project, layer, scale)
|
asset = None
|
||||||
elif layer_type == 'text':
|
for a in project.assets.filter(asset_type='joker', asset_key=which):
|
||||||
render_text_layer(canvas, layer, scale)
|
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
|
return canvas
|
||||||
@@ -2,6 +2,7 @@ from rest_framework.decorators import api_view
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.conf import settings
|
||||||
from ..projects.models import Project
|
from ..projects.models import Project
|
||||||
from .utils import generate_card_png
|
from .utils import generate_card_png
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -9,12 +10,22 @@ import io
|
|||||||
import os
|
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'])
|
@api_view(['POST'])
|
||||||
def export_project(request, pk):
|
def export_project(request, pk):
|
||||||
"""
|
"""批量导出整副牌为 ZIP"""
|
||||||
批量导出整副牌为ZIP文件
|
|
||||||
请求体: { "resolution": "standard", "cards": "all" }
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
project = Project.objects.get(pk=pk)
|
project = Project.objects.get(pk=pk)
|
||||||
except Project.DoesNotExist:
|
except Project.DoesNotExist:
|
||||||
@@ -23,24 +34,13 @@ def export_project(request, pk):
|
|||||||
resolution = request.data.get('resolution', 'standard')
|
resolution = request.data.get('resolution', 'standard')
|
||||||
cards_filter = request.data.get('cards', 'all')
|
cards_filter = request.data.get('cards', 'all')
|
||||||
|
|
||||||
# 确定要导出的牌
|
|
||||||
cards = []
|
|
||||||
if cards_filter == 'all':
|
if cards_filter == 'all':
|
||||||
# 生成所有54张牌
|
cards = _all_card_keys(project)
|
||||||
for suit in ['spade', 'heart', 'club', 'diamond']:
|
|
||||||
for rank in ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10']:
|
|
||||||
cards.append(f"{suit}-{rank}")
|
|
||||||
for face in ['J', 'Q', 'K']:
|
|
||||||
cards.append(f"{suit}-{face}")
|
|
||||||
cards.extend(['joker-big', 'joker-small'])
|
|
||||||
|
|
||||||
if project.export_include_back:
|
|
||||||
cards.append('back')
|
|
||||||
else:
|
else:
|
||||||
cards = cards_filter if isinstance(cards_filter, list) else [cards_filter]
|
cards = cards_filter if isinstance(cards_filter, list) else [cards_filter]
|
||||||
|
|
||||||
# 创建ZIP文件
|
|
||||||
zip_buffer = io.BytesIO()
|
zip_buffer = io.BytesIO()
|
||||||
|
failed = []
|
||||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
for card_key in cards:
|
for card_key in cards:
|
||||||
try:
|
try:
|
||||||
@@ -50,31 +50,28 @@ def export_project(request, pk):
|
|||||||
img_buffer.seek(0)
|
img_buffer.seek(0)
|
||||||
zip_file.writestr(f"{card_key}.png", img_buffer.getvalue())
|
zip_file.writestr(f"{card_key}.png", img_buffer.getvalue())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 记录错误但继续处理其他牌
|
failed.append({'card': card_key, 'error': str(e)})
|
||||||
print(f"Error generating {card_key}: {str(e)}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
# 保存到media目录
|
export_dir = os.path.join(settings.MEDIA_ROOT, 'export', str(project.id))
|
||||||
export_dir = os.path.join('media', 'export', str(project.id))
|
|
||||||
os.makedirs(export_dir, exist_ok=True)
|
os.makedirs(export_dir, exist_ok=True)
|
||||||
zip_path = os.path.join(export_dir, 'cards.zip')
|
zip_path = os.path.join(export_dir, 'cards.zip')
|
||||||
|
|
||||||
with open(zip_path, 'wb') as f:
|
with open(zip_path, 'wb') as f:
|
||||||
f.write(zip_buffer.getvalue())
|
f.write(zip_buffer.getvalue())
|
||||||
|
|
||||||
|
download_url = f"{settings.MEDIA_URL}export/{project.id}/cards.zip"
|
||||||
return Response({
|
return Response({
|
||||||
'download_url': f'/media/export/{project.id}/cards.zip',
|
'download_url': download_url,
|
||||||
'card_count': len(cards)
|
'card_count': len(cards),
|
||||||
|
'failed': failed,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def export_single_card(request, pk, card_key):
|
def export_single_card(request, pk, card_key):
|
||||||
"""
|
"""导出单张牌 PNG"""
|
||||||
导出单张牌PNG
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
project = Project.objects.get(pk=pk)
|
project = Project.objects.get(pk=pk)
|
||||||
except Project.DoesNotExist:
|
except Project.DoesNotExist:
|
||||||
@@ -87,10 +84,8 @@ def export_single_card(request, pk, card_key):
|
|||||||
img_buffer = io.BytesIO()
|
img_buffer = io.BytesIO()
|
||||||
png.save(img_buffer, format='PNG')
|
png.save(img_buffer, format='PNG')
|
||||||
img_buffer.seek(0)
|
img_buffer.seek(0)
|
||||||
|
|
||||||
response = HttpResponse(img_buffer, content_type='image/png')
|
response = HttpResponse(img_buffer, content_type='image/png')
|
||||||
response['Content-Disposition'] = f'attachment; filename="{card_key}.png"'
|
response['Content-Disposition'] = f'attachment; filename="{card_key}.png"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|||||||
0
backend/apps/projects/management/__init__.py
Normal file
0
backend/apps/projects/management/__init__.py
Normal file
94
backend/apps/projects/management/commands/init_system.py
Normal file
94
backend/apps/projects/management/commands/init_system.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import os
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from apps.projects.models import Project, Asset
|
||||||
|
from apps.templates.models import CardTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Initialize cards design system with sample data'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write(self.style.SUCCESS('Starting initialization...'))
|
||||||
|
|
||||||
|
self.create_templates()
|
||||||
|
self.create_sample_project()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Initialization complete!'))
|
||||||
|
|
||||||
|
def create_templates(self):
|
||||||
|
"""创建示例模板"""
|
||||||
|
templates = [
|
||||||
|
{
|
||||||
|
'id': 'classic',
|
||||||
|
'name': '经典风格',
|
||||||
|
'description': '标准扑克牌设计,传统花色和字体',
|
||||||
|
'color_spade': '#000000',
|
||||||
|
'color_heart': '#E53935',
|
||||||
|
'color_club': '#000000',
|
||||||
|
'color_diamond': '#E53935',
|
||||||
|
'color_background': '#FFFFFF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'modern',
|
||||||
|
'name': '现代简约',
|
||||||
|
'description': '扁平化设计,简洁线条',
|
||||||
|
'color_spade': '#333333',
|
||||||
|
'color_heart': '#E53935',
|
||||||
|
'color_club': '#333333',
|
||||||
|
'color_diamond': '#E53935',
|
||||||
|
'color_background': '#FAFAFA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'cartoon',
|
||||||
|
'name': '卡通风格',
|
||||||
|
'description': 'Q版可爱人像,圆润花色图案',
|
||||||
|
'color_spade': '#4A4A4A',
|
||||||
|
'color_heart': '#FF6B9D',
|
||||||
|
'color_club': '#4A4A4A',
|
||||||
|
'color_diamond': '#FF6B9D',
|
||||||
|
'color_background': '#FFF9E6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'vintage',
|
||||||
|
'name': '复古风格',
|
||||||
|
'description': '复古色调和纹理,装饰性边框',
|
||||||
|
'color_spade': '#2C1810',
|
||||||
|
'color_heart': '#8B4513',
|
||||||
|
'color_club': '#2C1810',
|
||||||
|
'color_diamond': '#8B4513',
|
||||||
|
'color_background': '#F5DEB3',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for td in templates:
|
||||||
|
template, created = CardTemplate.objects.update_or_create(
|
||||||
|
id=td['id'],
|
||||||
|
defaults={
|
||||||
|
'name': td['name'],
|
||||||
|
'description': td['description'],
|
||||||
|
'color_spade': td['color_spade'],
|
||||||
|
'color_heart': td['color_heart'],
|
||||||
|
'color_club': td['color_club'],
|
||||||
|
'color_diamond': td['color_diamond'],
|
||||||
|
'color_background': td['color_background'],
|
||||||
|
'default_assets': td,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
verb = 'created' if created else 'updated'
|
||||||
|
self.stdout.write(f' template {template.id} {verb}')
|
||||||
|
|
||||||
|
def create_sample_project(self):
|
||||||
|
"""创建示例项目:完整可玩的 54 张牌"""
|
||||||
|
project, created = Project.objects.update_or_create(
|
||||||
|
name="示例项目",
|
||||||
|
defaults=dict(
|
||||||
|
template_id='classic',
|
||||||
|
card_width=750,
|
||||||
|
card_height=1050,
|
||||||
|
export_resolution='standard',
|
||||||
|
export_include_back=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
verb = 'created' if created else 'updated'
|
||||||
|
self.stdout.write(f' project "{project.name}" {verb}')
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'示例项目 ID: {project.id}'))
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-06-01 05:55
|
||||||
|
|
||||||
|
import apps.projects.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='card_overrides',
|
||||||
|
field=models.JSONField(default=apps.projects.models.default_card_overrides),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='design',
|
||||||
|
field=models.JSONField(default=apps.projects.models.default_design),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='face_orientations',
|
||||||
|
field=models.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='number_layout',
|
||||||
|
field=models.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='asset',
|
||||||
|
name='file_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='asset',
|
||||||
|
name='file_path',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='asset',
|
||||||
|
name='height',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='asset',
|
||||||
|
name='width',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,6 +2,40 @@ from django.db import models
|
|||||||
import uuid
|
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):
|
class Project(models.Model):
|
||||||
"""项目配置模型"""
|
"""项目配置模型"""
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
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')
|
template_id = models.CharField(max_length=50, default='classic')
|
||||||
card_width = models.IntegerField(default=750)
|
card_width = models.IntegerField(default=750)
|
||||||
card_height = models.IntegerField(default=1050)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -26,12 +69,12 @@ class Project(models.Model):
|
|||||||
class Asset(models.Model):
|
class Asset(models.Model):
|
||||||
"""项目素材模型"""
|
"""项目素材模型"""
|
||||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets')
|
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'
|
asset_key = models.CharField(max_length=50) # 如 'spade', 'heart-J', 'big_joker'
|
||||||
file_path = models.CharField(max_length=255) # 相对于media目录
|
file_path = models.CharField(max_length=255, blank=True) # 相对于media目录
|
||||||
file_name = models.CharField(max_length=100)
|
file_name = models.CharField(max_length=100, blank=True)
|
||||||
width = models.IntegerField(null=True)
|
width = models.IntegerField(null=True, blank=True)
|
||||||
height = models.IntegerField(null=True)
|
height = models.IntegerField(null=True, blank=True)
|
||||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -42,12 +85,12 @@ class Asset(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class CardLayer(models.Model):
|
class CardLayer(models.Model):
|
||||||
"""牌面图层配置模型"""
|
"""牌面图层配置模型(图层顺序、可见性等)"""
|
||||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='layers')
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='layers')
|
||||||
card_type = models.CharField(max_length=20) # 'number', 'face', 'joker'
|
card_type = models.CharField(max_length=20) # 'number', 'face', 'joker', 'back'
|
||||||
card_key = models.CharField(max_length=30) # 'spade-A', 'heart-K', 'big_joker'
|
card_key = models.CharField(max_length=30) # 'spade-A', 'heart-K', 'joker-big'
|
||||||
layer_name = models.CharField(max_length=50)
|
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)
|
visible = models.BooleanField(default=True)
|
||||||
locked = models.BooleanField(default=False)
|
locked = models.BooleanField(default=False)
|
||||||
opacity = models.FloatField(default=1.0)
|
opacity = models.FloatField(default=1.0)
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ from .models import Project, Asset, CardLayer
|
|||||||
class ProjectSerializer(serializers.ModelSerializer):
|
class ProjectSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
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):
|
class AssetSerializer(serializers.ModelSerializer):
|
||||||
@@ -14,7 +20,8 @@ class AssetSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Asset
|
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):
|
def get_file_url(self, obj):
|
||||||
if obj.file_path:
|
if obj.file_path:
|
||||||
@@ -37,4 +44,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = '__all__'
|
fields = [
|
||||||
|
'id', 'name', 'template_id',
|
||||||
|
'card_width', 'card_height',
|
||||||
|
'design', 'card_overrides', 'number_layout', 'face_orientations',
|
||||||
|
'export_resolution', 'export_include_back',
|
||||||
|
'assets', 'layers',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from django.urls import path
|
from 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 = [
|
urlpatterns = [
|
||||||
path('', project_list, name='project-list'),
|
path('', project_list, name='project-list'),
|
||||||
path('<str:pk>/', project_detail, name='project-detail'),
|
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/', asset_list, name='asset-list'),
|
||||||
path('<str:project_pk>/assets/<str:asset_pk>/', asset_detail, name='asset-detail'),
|
path('<str:project_pk>/assets/<str:asset_pk>/', asset_detail, name='asset-detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ def project_list(request):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
elif request.method == 'POST':
|
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():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
@@ -38,7 +46,7 @@ def project_detail(request, pk):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
elif request.method == 'PUT':
|
elif request.method == 'PUT':
|
||||||
serializer = ProjectSerializer(project, data=request.data)
|
serializer = ProjectSerializer(project, data=request.data, partial=True)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
@@ -49,6 +57,27 @@ def project_detail(request, pk):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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'])
|
@api_view(['GET', 'POST'])
|
||||||
def asset_list(request, project_pk):
|
def asset_list(request, project_pk):
|
||||||
"""获取项目素材列表或上传新素材"""
|
"""获取项目素材列表或上传新素材"""
|
||||||
@@ -59,7 +88,7 @@ def asset_list(request, project_pk):
|
|||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
assets = project.assets.all()
|
assets = project.assets.all()
|
||||||
serializer = AssetSerializer(assets, many=True)
|
serializer = AssetSerializer(assets, many=True, context={'request': request})
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
elif request.method == 'POST':
|
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)
|
full_dir = os.path.join(settings.MEDIA_ROOT, project_media_dir)
|
||||||
os.makedirs(full_dir, exist_ok=True)
|
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)
|
file_path = os.path.join(project_media_dir, file_name)
|
||||||
saved_path = default_storage.save(file_path, file)
|
saved_path = default_storage.save(file_path, file)
|
||||||
|
|
||||||
# 获取图片尺寸
|
# 获取图片尺寸
|
||||||
|
width, height = None, None
|
||||||
try:
|
try:
|
||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
except:
|
except Exception:
|
||||||
width, height = None, None
|
pass
|
||||||
|
|
||||||
# 创建Asset记录
|
|
||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
asset_type=asset_type,
|
asset_type=asset_type,
|
||||||
@@ -95,10 +126,10 @@ def asset_list(request, project_pk):
|
|||||||
file_path=saved_path,
|
file_path=saved_path,
|
||||||
file_name=file_name,
|
file_name=file_name,
|
||||||
width=width,
|
width=width,
|
||||||
height=height
|
height=height,
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = AssetSerializer(asset)
|
serializer = AssetSerializer(asset, context={'request': request})
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
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)
|
return Response({'error': 'Asset not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
serializer = AssetSerializer(asset)
|
serializer = AssetSerializer(asset, context={'request': request})
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
elif request.method == 'DELETE':
|
elif request.method == 'DELETE':
|
||||||
# 删除文件
|
|
||||||
if asset.file_path:
|
if asset.file_path:
|
||||||
file_full_path = os.path.join(settings.MEDIA_ROOT, asset.file_path)
|
file_full_path = os.path.join(settings.MEDIA_ROOT, asset.file_path)
|
||||||
if os.path.exists(file_full_path):
|
if os.path.exists(file_full_path):
|
||||||
|
try:
|
||||||
os.remove(file_full_path)
|
os.remove(file_full_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
asset.delete()
|
asset.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
113
frontend/src/components/AssetPanel.vue
Normal file
113
frontend/src/components/AssetPanel.vue
Normal 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>
|
||||||
@@ -1,46 +1,67 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
title="上传素材"
|
:title="title"
|
||||||
width="500px"
|
width="480px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<el-form :model="form" label-width="100px">
|
<el-form :model="form" label-width="100px">
|
||||||
<el-form-item label="素材类型">
|
<el-form-item label="素材类型" required>
|
||||||
<el-select v-model="form.assetType" placeholder="选择素材类型">
|
<el-select v-model="form.assetType" placeholder="选择类型" style="width: 100%;">
|
||||||
<el-option label="花色图案" value="suit_symbol" />
|
<el-option label="JQK 人物图" value="face_card" />
|
||||||
<el-option label="JQK人像" value="face_card" />
|
<el-option label="大王/小王图" value="joker" />
|
||||||
<el-option label="大小王" value="joker" />
|
|
||||||
<el-option label="背面图案" value="back" />
|
<el-option label="背面图案" value="back" />
|
||||||
|
<el-option label="花色符号图" value="suit_symbol" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="素材标识">
|
<el-form-item label="素材标识" required>
|
||||||
<el-input
|
<el-select
|
||||||
|
v-if="form.assetType === 'face_card' || form.assetType === 'joker' || form.assetType === 'suit_symbol'"
|
||||||
v-model="form.assetKey"
|
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>
|
||||||
|
|
||||||
<el-form-item label="选择文件">
|
<el-form-item label="选择文件" required>
|
||||||
<el-upload
|
<input
|
||||||
ref="upload"
|
ref="fileInput"
|
||||||
drag
|
type="file"
|
||||||
:auto-upload="false"
|
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||||
:on-change="handleFileChange"
|
@change="handleFileChange"
|
||||||
:limit="1"
|
style="display: none;"
|
||||||
:on-exceed="handleExceed"
|
/>
|
||||||
>
|
<div class="upload-area" @click="fileInput?.click()" @drop.prevent="handleDrop" @dragover.prevent>
|
||||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
<div v-if="!form.preview" class="placeholder">
|
||||||
<div class="el-upload__text">
|
<div class="icon">+</div>
|
||||||
拖拽文件到此处或<em>点击上传</em>
|
<div>点击或拖拽图片到此处</div>
|
||||||
|
<div class="hint">支持 PNG / JPG / SVG / WebP,建议透明背景</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="preview">
|
||||||
|
<img :src="form.preview" alt="preview" />
|
||||||
|
<div class="filename">{{ form.file?.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template #tip>
|
|
||||||
<div class="el-upload__tip">
|
|
||||||
支持 PNG, JPG, SVG 格式
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</el-upload>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
@@ -61,44 +82,59 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { UploadFilled } from '@element-plus/icons-vue'
|
import axios from 'axios'
|
||||||
import { uploadAsset } from '@/api/asset'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
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 dialogVisible = ref(props.modelValue)
|
||||||
const upload = ref(null)
|
const fileInput = ref(null)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const selectedFile = ref(null)
|
const form = ref({ assetType: 'face_card', assetKey: '', file: null, preview: null })
|
||||||
|
|
||||||
const form = ref({
|
const suitOptions = ['spade', 'heart', 'club', 'diamond']
|
||||||
assetType: 'suit_symbol',
|
const faceRanks = ['J', 'Q', 'K']
|
||||||
assetKey: '',
|
const suitSymbol = (s) => ({ spade: '♠', heart: '♥', club: '♣', diamond: '♦' })[s]
|
||||||
file: null
|
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(() => {
|
const canSubmit = computed(() => {
|
||||||
return form.value.assetKey && selectedFile.value
|
return form.value.assetType && form.value.assetKey && form.value.file
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => { dialogVisible.value = val })
|
||||||
dialogVisible.value = val
|
watch(dialogVisible, (val) => { emit('update:modelValue', val) })
|
||||||
})
|
watch(() => form.value.assetType, () => { form.value.assetKey = '' })
|
||||||
|
|
||||||
watch(dialogVisible, (val) => {
|
function handleFileChange(e) {
|
||||||
emit('update:modelValue', val)
|
const f = e.target.files[0]
|
||||||
})
|
if (f) acceptFile(f)
|
||||||
|
|
||||||
function handleFileChange(file) {
|
|
||||||
selectedFile.value = file.raw
|
|
||||||
}
|
}
|
||||||
|
function handleDrop(e) {
|
||||||
function handleExceed() {
|
const f = e.dataTransfer.files[0]
|
||||||
ElMessage.warning('只能上传一个文件')
|
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() {
|
async function handleSubmit() {
|
||||||
@@ -106,22 +142,20 @@ async function handleSubmit() {
|
|||||||
ElMessage.warning('请填写完整信息')
|
ElMessage.warning('请填写完整信息')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const fd = new FormData()
|
||||||
try {
|
fd.append('file', form.value.file)
|
||||||
|
fd.append('asset_type', form.value.assetType)
|
||||||
|
fd.append('asset_key', form.value.assetKey)
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
await uploadAsset(
|
try {
|
||||||
props.projectId,
|
const r = await axios.post(`/api/projects/${props.projectId}/assets/`, fd, {
|
||||||
selectedFile.value,
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
form.value.assetType,
|
})
|
||||||
form.value.assetKey
|
|
||||||
)
|
|
||||||
|
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success('上传成功')
|
||||||
emit('upload-success')
|
emit('uploaded', r.data)
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
ElMessage.error('上传失败')
|
ElMessage.error('上传失败: ' + (e.response?.data?.error || e.message))
|
||||||
console.error(error)
|
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
@@ -130,25 +164,28 @@ async function handleSubmit() {
|
|||||||
function handleClose() {
|
function handleClose() {
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
// Reset form
|
form.value = { assetType: 'face_card', assetKey: '', file: null, preview: null }
|
||||||
form.value = {
|
|
||||||
assetType: 'suit_symbol',
|
|
||||||
assetKey: '',
|
|
||||||
file: null
|
|
||||||
}
|
|
||||||
selectedFile.value = null
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.el-upload__text {
|
.upload-area {
|
||||||
color: #606266;
|
width: 100%;
|
||||||
font-size: 14px;
|
min-height: 140px;
|
||||||
}
|
border: 2px dashed #555;
|
||||||
|
border-radius: 8px;
|
||||||
.el-upload__tip {
|
display: flex;
|
||||||
color: #909399;
|
align-items: center;
|
||||||
font-size: 12px;
|
justify-content: center;
|
||||||
margin-top: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
218
frontend/src/components/DesignPanel.vue
Normal file
218
frontend/src/components/DesignPanel.vue
Normal 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>
|
||||||
197
frontend/src/stores/projectStore.js
Normal file
197
frontend/src/stores/projectStore.js
Normal 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
|
||||||
|
}
|
||||||
@@ -1,124 +1,88 @@
|
|||||||
const LAYOUT_POSITIONS = {
|
/**
|
||||||
1: [
|
* 扑克牌布局数据 & 通用工具函数
|
||||||
{ x: 0.5, y: 0.5 }
|
*
|
||||||
],
|
* 这里和后端 apps/exports/utils.py 保持一致。
|
||||||
2: [
|
* 实际渲染在前端用 canvas(drawCard),后端用 PIL(generate_card_png)。
|
||||||
{ x: 0.5, y: 0.25 },
|
*/
|
||||||
{ x: 0.5, y: 0.75 }
|
|
||||||
],
|
// 数字牌 1-10 的花色位置(相对坐标 0~1)
|
||||||
3: [
|
export const LAYOUT_POSITIONS = {
|
||||||
{ x: 0.5, y: 0.2 },
|
1: [{ x: 0.50, y: 0.50 }],
|
||||||
{ x: 0.5, y: 0.5 },
|
2: [{ x: 0.50, y: 0.25 }, { x: 0.50, y: 0.75 }],
|
||||||
{ x: 0.5, y: 0.8 }
|
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 }],
|
||||||
4: [
|
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 }],
|
||||||
{ x: 0.3, y: 0.25 },
|
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 }],
|
||||||
{ x: 0.7, y: 0.25 },
|
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 }],
|
||||||
{ x: 0.3, y: 0.75 },
|
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 }],
|
||||||
{ x: 0.7, y: 0.75 }
|
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 }],
|
||||||
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 }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateSuitPositions(rank, cardWidth, cardHeight, symbolSize = 60) {
|
export const SUIT_TEXT = {
|
||||||
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 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 function getSuitSymbol(suit) {
|
|
||||||
const symbols = {
|
|
||||||
spade: '♠',
|
spade: '♠',
|
||||||
heart: '♥',
|
heart: '♥',
|
||||||
club: '♣',
|
club: '♣',
|
||||||
diamond: '♦'
|
diamond: '♦',
|
||||||
}
|
|
||||||
return symbols[suit] || '♠'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSuitColor(suit, templateColors) {
|
export const SUIT_LABELS = {
|
||||||
if (templateColors && templateColors[suit]) {
|
spade: '♠ 黑桃',
|
||||||
return templateColors[suit]
|
heart: '♥ 红桃',
|
||||||
}
|
club: '♣ 梅花',
|
||||||
|
diamond: '♦ 方块',
|
||||||
|
}
|
||||||
|
|
||||||
const colors = {
|
export const SUIT_COLORS = {
|
||||||
spade: '#000000',
|
spade: '#000000',
|
||||||
heart: '#FF0000',
|
heart: '#E53935',
|
||||||
club: '#000000',
|
club: '#000000',
|
||||||
diamond: '#FF0000'
|
diamond: '#E53935',
|
||||||
}
|
|
||||||
return colors[suit] || '#000000'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
||||||
|
|
||||||
|
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) {
|
export function isRedSuit(suit) {
|
||||||
return suit === 'heart' || suit === 'diamond'
|
return suit === 'heart' || suit === 'diamond'
|
||||||
}
|
}
|
||||||
@@ -126,3 +90,49 @@ export function isRedSuit(suit) {
|
|||||||
export function isBlackSuit(suit) {
|
export function isBlackSuit(suit) {
|
||||||
return suit === 'spade' || suit === 'club'
|
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 },
|
||||||
|
}
|
||||||
|
|||||||
402
frontend/src/utils/cardRenderer.js
Normal file
402
frontend/src/utils/cardRenderer.js
Normal 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 }
|
||||||
@@ -1,364 +1,322 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="min-height: 100vh; background: #1a1a2e; color: #eee; font-family: sans-serif;">
|
<div class="editor" v-if="store.project">
|
||||||
<header style="background: #16213e; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center;">
|
<header class="topbar">
|
||||||
<div style="display: flex; align-items: center; gap: 20px;">
|
<div class="left">
|
||||||
<button @click="$router.push('/')" style="background: #333; color: #aaa; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">← 返回</button>
|
<button class="back" @click="router.push('/')">← 返回</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="项目名称">
|
<input
|
||||||
|
v-model="projectName"
|
||||||
|
@blur="onNameBlur"
|
||||||
|
@keydown.enter="$event.target.blur()"
|
||||||
|
class="title"
|
||||||
|
placeholder="项目名称"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 10px;">
|
<div class="right">
|
||||||
<button @click="doExportAll" style="padding: 8px 20px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer;">导出全部</button>
|
<span class="save-status" v-if="saving">保存中…</span>
|
||||||
<button @click="doExportSingle" style="padding: 8px 20px; background: #0f3460; color: #e94560; border: 1px solid #e94560; border-radius: 4px; cursor: pointer;">导出当前</button>
|
<span class="save-status ok" v-else-if="lastSaved">已保存</span>
|
||||||
|
<button class="ghost" @click="exportSingle">导出当前</button>
|
||||||
|
<button class="primary" @click="exportAll">导出整副牌 (ZIP)</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style="display: flex; height: calc(100vh - 60px);">
|
<div class="main">
|
||||||
<aside style="width: 260px; background: #0f3460; padding: 20px; overflow-y: auto;">
|
<!-- 左侧:牌面选择 + 编辑面板 -->
|
||||||
<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #888;">花色选择</h3>
|
<aside class="left-pane">
|
||||||
<div style="display: flex; gap: 8px; margin-bottom: 25px;">
|
<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>
|
||||||
|
|
||||||
|
<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"
|
<button v-for="s in suits" :key="s"
|
||||||
@click="switchSuit(s)"
|
:class="['suit-btn', suitOfCurrent, { active: suitOfCurrent === 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' }">
|
@click="switchSuit(s)">{{ suitSymbol(s) }}</button>
|
||||||
{{ suitLabels[s] }}
|
</div>
|
||||||
</button>
|
<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 class="card-list" v-else>
|
||||||
|
<button class="card-cell" :class="{ active: store.currentCard === 'back' }"
|
||||||
|
@click="store.currentCard = 'back'">背面</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #888;">图层管理</h3>
|
<div class="panel-title">设计编辑</div>
|
||||||
<div v-for="l in layers" :key="l.id"
|
<DesignPanel />
|
||||||
@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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</aside>
|
</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);">
|
<main class="canvas-pane">
|
||||||
<canvas ref="canvasEl" id="main-canvas"></canvas>
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- 右侧:素材管理 -->
|
||||||
|
<aside class="right-pane">
|
||||||
|
<AssetPanel />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer style="background: #0f3460; padding: 10px 20px; display: flex; gap: 5px; overflow-x: auto;">
|
<!-- 底部:54 张缩略图 -->
|
||||||
<div v-for="c in currentCards" :key="c.key"
|
<footer class="bottom-pane">
|
||||||
@click="selectCard(c.key)"
|
<div class="thumb-list">
|
||||||
: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' }">
|
<div
|
||||||
{{ c.label }}
|
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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="loading">加载项目中…</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { 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'
|
import axios from 'axios'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const store = useProjectStore()
|
||||||
|
const { project, currentCard } = storeToRefs(store)
|
||||||
|
|
||||||
const canvasEl = ref(null)
|
const projectName = ref('')
|
||||||
const pname = ref('')
|
const saving = ref(false)
|
||||||
const currentSuit = ref('spade')
|
const lastSaved = ref(false)
|
||||||
const currentCard = ref('spade-A')
|
const currentGroup = ref('number')
|
||||||
const fabricCanvas = ref(null)
|
const mainCanvas = ref(null)
|
||||||
const activeLayer = ref('bg')
|
const thumbRefs = ref({})
|
||||||
const projectId = computed(() => route.params.projectId)
|
let saveWatchStop = null
|
||||||
|
|
||||||
const suits = ['spade', 'heart', 'club', 'diamond']
|
const suits = SUITS
|
||||||
const suitLabels = { spade: '♠ 黑桃', heart: '♥ 红桃', club: '♣ 梅花', diamond: '♦ 方块' }
|
const suitSymbol = (s) => SUIT_TEXT[s]
|
||||||
|
const suitOfCurrent = computed(() => {
|
||||||
const ranks = ['A','2','3','4','5','6','7','8','9','10','J','Q','K']
|
if (!currentCard.value || !currentCard.value.includes('-')) return 'spade'
|
||||||
|
const [s] = currentCard.value.split('-')
|
||||||
const currentCards = computed(() => {
|
return s
|
||||||
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 layers = ref([
|
const cardGroups = [
|
||||||
{ id: 'bg', name: '背景层', visible: true, zIndex: 0, fillColor: '#ffffff', imageData: null },
|
{ id: 'number', label: '数字牌' },
|
||||||
{ id: 'border', name: '边框层', visible: true, zIndex: 1, strokeColor: '#333333', strokeWidth: 2 },
|
{ id: 'face', label: 'JQK' },
|
||||||
{ id: 'pattern', name: '图案层', visible: true, zIndex: 2 },
|
{ id: 'joker', label: '大小王' },
|
||||||
{ id: 'text', name: '文字层', visible: true, zIndex: 3, textSize: 32 }
|
{ id: 'back', label: '背面' },
|
||||||
])
|
]
|
||||||
|
|
||||||
const bgColor = ref('#ffffff')
|
const cardsInGroup = computed(() => {
|
||||||
const bgImageUrl = ref(null)
|
const s = suitOfCurrent.value
|
||||||
const borderColor = ref('#333333')
|
if (currentGroup.value === 'number') {
|
||||||
const borderWidth = ref(2)
|
return ['A','2','3','4','5','6','7','8','9','10'].map(r => ({
|
||||||
const textSize = ref(32)
|
key: `${s}-${r}`,
|
||||||
|
label: `${SUIT_TEXT[s]}${r}`,
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
if (currentGroup.value === 'face') {
|
||||||
}
|
return ['J','Q','K'].map(r => ({
|
||||||
|
key: `${s}-${r}`,
|
||||||
function clearBgImage() {
|
label: `${SUIT_TEXT[s]}${r}`,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
const bLayer = layers.value.find(x => x.id === 'border')
|
if (currentGroup.value === 'joker') {
|
||||||
if (bLayer) {
|
return [
|
||||||
borderColor.value = bLayer.strokeColor
|
{ key: 'joker-big', label: '大王' },
|
||||||
borderWidth.value = bLayer.strokeWidth
|
{ key: 'joker-small', label: '小王' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
const tLayer = layers.value.find(x => x.id === 'text')
|
return []
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function initCanvas() {
|
const allThumbs = computed(() => {
|
||||||
if (!canvasEl.value) return
|
// 按 spade/heart/club/diamond × 13 + 大小王 + 背
|
||||||
fabricCanvas.value = new Canvas('main-canvas', {
|
const out = []
|
||||||
width: 400,
|
for (const s of SUITS) {
|
||||||
height: 560,
|
for (const r of ['A','2','3','4','5','6','7','8','9','10','J','Q','K']) {
|
||||||
backgroundColor: '#f5f5f5',
|
out.push({ key: `${s}-${r}`, label: `${SUIT_TEXT[s]}${r}` })
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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) {
|
function switchSuit(s) {
|
||||||
currentSuit.value = s
|
const cur = store.currentCard
|
||||||
currentCard.value = `${s}-A`
|
if (cur && cur.includes('-')) {
|
||||||
drawCard()
|
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) {
|
watch(currentCard, () => { drawAll() })
|
||||||
currentCard.value = key
|
watch(() => project.value && project.value.assets, () => { drawAll() }, { deep: true })
|
||||||
drawCard()
|
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() {
|
onMounted(async () => {
|
||||||
if (!projectId.value) return
|
|
||||||
try { await axios.put(`/api/projects/${projectId.value}/`, { name: pname.value }) } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doExportAll() {
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`/api/projects/${projectId.value}/export/`, { resolution: 'standard', cards: 'all' })
|
await store.loadProject(route.params.projectId)
|
||||||
if (res.data.download_url) window.open(res.data.download_url, '_blank')
|
projectName.value = project.value.name
|
||||||
} catch (e) { alert('导出失败: ' + e.message) }
|
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() {
|
async function drawAll() {
|
||||||
const url = `/api/projects/${projectId.value}/export/${currentCard.value}/?resolution=standard`
|
if (!project.value) return
|
||||||
window.open(url, '_blank')
|
// 主画布
|
||||||
|
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>
|
</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>
|
||||||
|
|||||||
@@ -1,91 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="min-height: 100vh; background: #1a1a2e; color: #eee; font-family: sans-serif;">
|
<div class="home">
|
||||||
<header style="background: #16213e; padding: 25px 40px; display: flex; justify-content: space-between; align-items: center;">
|
<header class="topbar">
|
||||||
<h1 style="margin: 0; font-size: 28px; color: #e94560;">扑克牌设计管理系统</h1>
|
<h1>扑克牌设计管理系统</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 class="primary" @click="doCreate">+ 新建空白项目</button>
|
||||||
创建新项目
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main style="max-width: 1200px; margin: 0 auto; padding: 40px 20px;">
|
<main>
|
||||||
<h2 style="margin-bottom: 30px; font-size: 22px;">选择模板系列开始设计</h2>
|
<section>
|
||||||
|
<h2>选择模板系列开始设计</h2>
|
||||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 50px;">
|
<div class="template-grid">
|
||||||
<div v-for="t in templateList" :key="t.id"
|
<div v-for="t in templateList" :key="t.id"
|
||||||
@click="createFromTemplate(t.id)"
|
class="template-card"
|
||||||
style="background: #0f3460; border-radius: 12px; padding: 25px; cursor: pointer; transition: all 0.3s; text-align: center;"
|
@click="createFromTemplate(t.id)">
|
||||||
@mouseenter="$event.currentTarget.style.background = '#16213e'"
|
<div class="icon">{{ t.icon }}</div>
|
||||||
@mouseleave="$event.currentTarget.style.background = '#0f3460'">
|
<h3>{{ t.name }}</h3>
|
||||||
<div style="font-size: 48px; margin-bottom: 12px;">{{ t.icon }}</div>
|
<p>{{ t.desc }}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="hasProjects" style="background: #0f3460; border-radius: 12px; padding: 25px;">
|
<section v-if="hasProjects" class="project-list">
|
||||||
<h3 style="margin: 0 0 20px 0;">已有项目</h3>
|
<h3>已有项目</h3>
|
||||||
<div v-for="p in projectList" :key="p.id"
|
<div v-for="p in projects" :key="p.id" class="project-row">
|
||||||
style="display: flex; justify-content: space-between; align-items: center; padding: 15px; background: #16213e; border-radius: 8px; margin-bottom: 10px;">
|
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ p.name }}</strong>
|
<strong>{{ p.name }}</strong>
|
||||||
<div style="font-size: 12px; color: #888; margin-top: 4px;">创建于: {{ formatDate(p.created_at) }}</div>
|
<div class="meta">
|
||||||
</div>
|
模板 {{ p.template_id }} · 创建于 {{ formatDate(p.created_at) }}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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="loading" class="muted">加载中…</div>
|
||||||
<div v-if="loadError" style="text-align: center; padding: 40px; color: #e94560;">{{ loadError }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import axios from 'axios'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useProjectStore } from '@/stores/projectStore.js'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const projectList = ref([])
|
const store = useProjectStore()
|
||||||
const loading = ref(false)
|
const { projects, loading, error } = storeToRefs(store)
|
||||||
const loadError = ref('')
|
|
||||||
|
|
||||||
const templateList = [
|
const templateList = [
|
||||||
{ id: 'classic', name: '经典风格', desc: '标准扑克牌设计,传统花色和字体', icon: '♠' },
|
{ id: 'classic', name: '经典风格', desc: '标准扑克牌设计,传统花色和字体', icon: '♠' },
|
||||||
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
|
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
|
||||||
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像,圆润花色图案', icon: '★' },
|
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像,圆润花色图案', icon: '★' },
|
||||||
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' }
|
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const hasProjects = computed(() => projectList.value.length > 0)
|
const hasProjects = computed(() => projects.value.length > 0)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => store.fetchProjects())
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doCreate() {
|
async function doCreate() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post('/api/projects/', {
|
const p = await store.createProject('新项目 ' + new Date().toLocaleDateString())
|
||||||
name: '新项目 ' + new Date().toLocaleDateString(),
|
router.push('/editor/' + p.id)
|
||||||
template_id: 'classic'
|
|
||||||
})
|
|
||||||
router.push('/editor/' + res.data.id)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('创建失败: ' + e.message)
|
alert('创建失败: ' + e.message)
|
||||||
}
|
}
|
||||||
@@ -94,11 +74,8 @@ async function doCreate() {
|
|||||||
async function createFromTemplate(tid) {
|
async function createFromTemplate(tid) {
|
||||||
try {
|
try {
|
||||||
const nm = templateList.find(t => t.id === tid)?.name || tid
|
const nm = templateList.find(t => t.id === tid)?.name || tid
|
||||||
const res = await axios.post('/api/projects/', {
|
const p = await store.createProject(nm + ' - ' + new Date().toLocaleDateString(), tid)
|
||||||
name: nm + ' - 新项目',
|
router.push('/editor/' + p.id)
|
||||||
template_id: tid
|
|
||||||
})
|
|
||||||
router.push('/editor/' + res.data.id)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('创建失败: ' + e.message)
|
alert('创建失败: ' + e.message)
|
||||||
}
|
}
|
||||||
@@ -109,10 +86,9 @@ function editProject(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeProject(id) {
|
async function removeProject(id) {
|
||||||
if (!confirm('确定删除?')) return
|
if (!confirm('确定删除这个项目?')) return
|
||||||
try {
|
try {
|
||||||
await axios.delete('/api/projects/' + id + '/')
|
await store.deleteProject(id)
|
||||||
await loadProjects()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('删除失败: ' + e.message)
|
alert('删除失败: ' + e.message)
|
||||||
}
|
}
|
||||||
@@ -122,3 +98,29 @@ function formatDate(d) {
|
|||||||
return new Date(d).toLocaleString('zh-CN')
|
return new Date(d).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,67 +1,126 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding: 20px; font-family: Arial, sans-serif;">
|
<div class="test-page">
|
||||||
|
<header>
|
||||||
<h1>扑克牌设计管理系统 - 测试页面</h1>
|
<h1>扑克牌设计管理系统 - 测试页面</h1>
|
||||||
<p>如果你能看到这个页面,说明Vue正常工作</p>
|
<p>如果看到这个页面,Vue 工作正常。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div style="margin-top: 20px;">
|
<section>
|
||||||
<h2>API测试</h2>
|
<h2>系统健康检查</h2>
|
||||||
<button @click="testAPI" style="padding: 10px 20px; cursor: pointer;">
|
<div class="row">
|
||||||
测试API连接
|
<button @click="checkBackend">连接后端</button>
|
||||||
</button>
|
<button @click="checkProject">获取项目列表</button>
|
||||||
<p v-if="apiResult">API返回: {{ apiResult }}</p>
|
<button @click="checkCardLayout">测试牌面布局</button>
|
||||||
<p v-if="apiError" style="color: red;">错误: {{ apiError }}</p>
|
<button @click="testRenderer">测试 Canvas 渲染</button>
|
||||||
</div>
|
</div>
|
||||||
|
<pre v-if="apiResult" class="result">{{ apiResult }}</pre>
|
||||||
|
<p v-if="apiError" class="error">错误: {{ apiError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div style="margin-top: 20px;">
|
<section v-if="rendered">
|
||||||
<h2>项目列表</h2>
|
<h2>Canvas 渲染测试</h2>
|
||||||
<div v-if="loading">加载中...</div>
|
<p>所有 4 个花色的 A 牌 + J + 大小王 + 背面</p>
|
||||||
<div v-else-if="projects.length > 0">
|
<div class="card-row">
|
||||||
<div v-for="project in projects" :key="project.id" style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
|
<div v-for="ck in testKeys" :key="ck" class="card-cell">
|
||||||
<h3>{{ project.name }}</h3>
|
<canvas :ref="el => setRef(ck, el)" width="120" height="168"></canvas>
|
||||||
<p>ID: {{ project.id }}</p>
|
<div class="label">{{ ck }}</div>
|
||||||
<p>创建时间: {{ project.created_at }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>没有项目</div>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import axios from 'axios'
|
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 apiResult = ref('')
|
||||||
const apiError = 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 () => {
|
function setRef(key, el) {
|
||||||
await loadProjects()
|
if (el) canvasRefs.value[key] = el
|
||||||
})
|
|
||||||
|
|
||||||
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() {
|
async function checkBackend() {
|
||||||
try {
|
|
||||||
const response = await axios.get('/')
|
|
||||||
apiResult.value = JSON.stringify(response.data, null, 2)
|
|
||||||
apiError.value = ''
|
apiError.value = ''
|
||||||
} catch (error) {
|
try {
|
||||||
apiError.value = error.message
|
const r = await axios.get('/')
|
||||||
apiResult.value = ''
|
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(() => {
|
||||||
|
// 自动跑一次基础检查
|
||||||
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user