feat: 预设素材库 - 4 套主题 × 4 张图 = 16 张 PNG
主题:classical 古典宫廷(王子/皇后/国王/小丑)
modern 现代人物(小孩/女青年/男青年/小丑鱼)
astronomy 天文(星星/月亮/太阳/黑洞)
minimal 简笔符号(圆点/♀/♂/叉)
改动:
- 新增 LibraryAsset 模型(全局素材库,theme_id/role/asset_id 索引)
- 新增 seed_library 管理命令,用 Pillow 画 16 张 PNG 素材
- 新增 /api/projects/library/ 列表 API
- 新增 /api/projects/{pid}/library/{id}/apply/ 应用 API
把预设素材复制到 projects/<pid>/joker 或 face_card 下,作为该牌位的素材
- AssetPanel 加 tab 切换:「我的素材」+「预设主题」,主题可筛选、点击套用到当前牌
- 修复 generate_card_png 的 joker asset 匹配 bug:which 应该是 card_key(含前缀)才能匹配 asset_key
设计要点:
- 预设素材只画上半身(y=0~150),下半留空,让系统的'自动对称'流水线正确工作
- 预设素材 PNG 200×300,深色描边 + 半透明浅色填充,在任意牌面背景上叠加都清晰
@@ -430,9 +430,10 @@ def generate_card_png(project, card_key, resolution='standard'):
|
|||||||
|
|
||||||
# 3. 主体内容
|
# 3. 主体内容
|
||||||
if card_key.startswith('joker-'):
|
if card_key.startswith('joker-'):
|
||||||
|
# which 是去掉前缀的 'big'/'small',但 asset_key 含 'joker-' 前缀
|
||||||
which = card_key.split('-', 1)[1] # big / small
|
which = card_key.split('-', 1)[1] # big / small
|
||||||
asset = None
|
asset = None
|
||||||
for a in project.assets.filter(asset_type='joker', asset_key=which):
|
for a in project.assets.filter(asset_type='joker', asset_key=card_key):
|
||||||
p = os.path.join('media', a.file_path) if a.file_path else None
|
p = os.path.join('media', a.file_path) if a.file_path else None
|
||||||
asset = load_image_safe(p) if p else None
|
asset = load_image_safe(p) if p else None
|
||||||
break
|
break
|
||||||
|
|||||||
428
backend/apps/projects/management/commands/seed_library.py
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
"""
|
||||||
|
初始化预设素材库:用 Pillow 直接画 PNG(200×300),存到 media/library/,并写入 LibraryAsset 表。
|
||||||
|
|
||||||
|
主题(4 套 × 4 张 = 16 张):
|
||||||
|
classical 古典宫廷:王子/皇后/国王/宫廷小丑
|
||||||
|
modern 现代人物:小孩/女青年/男青年/小丑鱼
|
||||||
|
astronomy 天文: 星星/月亮/太阳/黑洞
|
||||||
|
minimal 简笔符号:圆点/♀/♂/叉
|
||||||
|
|
||||||
|
风格:深色描边 4px + 半透明浅色填充,便于在任意牌面背景上叠加。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
from apps.projects.models import LibraryAsset
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
W, H = 200, 300
|
||||||
|
STROKE = (26, 26, 26, 255)
|
||||||
|
STROKE_W = 4
|
||||||
|
# 关键:素材主体只画在 y=20~200(上半身),y=200~300 留空。
|
||||||
|
# 因为后端会把整图 fill 到 body 区域,再切上半 + 翻转下半拼成上下对称,
|
||||||
|
# 所以素材本身只需要"上半身",下半由系统自动翻转生成。
|
||||||
|
BODY_TOP = 20
|
||||||
|
BODY_BOTTOM = 200
|
||||||
|
|
||||||
|
|
||||||
|
def new_canvas():
|
||||||
|
img = Image.new('RGBA', (W, H), (255, 255, 255, 0))
|
||||||
|
return img, ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
|
||||||
|
def shift_up(img, dy=-30):
|
||||||
|
"""把图整体上移 dy 像素,下方留空。
|
||||||
|
因为素材只需要画上半身,下半由系统对称生成。"""
|
||||||
|
out = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
out.paste(img, (0, dy), img)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def crop_to_upper(img):
|
||||||
|
"""把图裁到 y=0~150 区域(只保留上半身),下面 150px 留空。
|
||||||
|
后端 fill 整图后切上下半 = 上半=原图上半(0~150),下半=原图下半(150~300 留空)
|
||||||
|
→ 翻转下半也是空白 → 整张牌上半身 + 空白(不会有奇怪的下半)"""
|
||||||
|
out = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
upper = img.crop((0, 0, W, 150))
|
||||||
|
out.paste(upper, (0, 0), upper)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- 古典宫廷 -----------------
|
||||||
|
def classical_prince():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 头发
|
||||||
|
d.polygon([(50, 110), (55, 130), (145, 130), (150, 110), (145, 100), (55, 100)],
|
||||||
|
fill=(180, 140, 80, 100), outline=STROKE, width=STROKE_W)
|
||||||
|
# 脸
|
||||||
|
d.ellipse((60, 100, 140, 180), fill=(255, 224, 189, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
# 眼睛
|
||||||
|
d.ellipse((85, 134, 91, 140), fill=STROKE)
|
||||||
|
d.ellipse((109, 134, 115, 140), fill=STROKE)
|
||||||
|
# 嘴
|
||||||
|
d.arc((90, 148, 110, 162), start=0, end=180, fill=STROKE, width=3)
|
||||||
|
# 小皇冠
|
||||||
|
d.polygon([(70, 78), (78, 60), (86, 75), (100, 55), (114, 75), (122, 60), (130, 78)],
|
||||||
|
fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
d.ellipse((96, 52, 104, 60), fill=(255, 220, 80, 200), outline=STROKE, width=2)
|
||||||
|
# 衣领
|
||||||
|
d.line([(60, 200), (100, 220), (140, 200)], fill=STROKE, width=STROKE_W)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def classical_queen():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 长发(披肩)
|
||||||
|
d.polygon([(45, 150), (50, 220), (150, 220), (155, 150),
|
||||||
|
(160, 100), (140, 80), (60, 80), (40, 100)],
|
||||||
|
fill=(200, 80, 80, 100), outline=STROKE, width=STROKE_W)
|
||||||
|
# 脸
|
||||||
|
d.ellipse((62, 102, 138, 178), fill=(255, 224, 189, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
# 眼睛
|
||||||
|
d.ellipse((85, 134, 91, 140), fill=STROKE)
|
||||||
|
d.ellipse((109, 134, 115, 140), fill=STROKE)
|
||||||
|
# 嘴
|
||||||
|
d.arc((90, 150, 110, 164), start=0, end=180, fill=(200, 60, 60, 255), width=3)
|
||||||
|
# 高尖皇冠
|
||||||
|
d.polygon([(75, 75), (100, 30), (125, 75)],
|
||||||
|
fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
d.ellipse((96, 30, 104, 40), fill=(255, 220, 80, 200), outline=STROKE, width=2)
|
||||||
|
# 皇冠装饰线
|
||||||
|
d.arc((80, 65, 120, 85), start=0, end=180, fill=STROKE, width=2)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def classical_king():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 头发
|
||||||
|
d.polygon([(55, 115), (58, 135), (142, 135), (145, 115), (140, 100), (60, 100)],
|
||||||
|
fill=(120, 90, 60, 100), outline=STROKE, width=STROKE_W)
|
||||||
|
# 脸
|
||||||
|
d.ellipse((60, 100, 140, 180), fill=(255, 224, 189, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
# 眼睛
|
||||||
|
d.ellipse((85, 130, 91, 136), fill=STROKE)
|
||||||
|
d.ellipse((109, 130, 115, 136), fill=STROKE)
|
||||||
|
# 嘴
|
||||||
|
d.line([(88, 152), (112, 152)], fill=STROKE, width=3)
|
||||||
|
# 大胡子
|
||||||
|
d.polygon([(75, 158), (80, 178), (100, 185), (120, 178), (125, 158),
|
||||||
|
(115, 170), (100, 175), (85, 170)],
|
||||||
|
fill=(120, 90, 60, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 厚重皇冠
|
||||||
|
d.polygon([(65, 88), (70, 65), (85, 80), (100, 50), (115, 80), (130, 65), (135, 88)],
|
||||||
|
fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 皇冠红宝石
|
||||||
|
d.rectangle((95, 70, 105, 76), fill=(200, 40, 40, 200), outline=STROKE, width=1)
|
||||||
|
# 衣领
|
||||||
|
d.line([(55, 205), (100, 225), (145, 205)], fill=STROKE, width=STROKE_W)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def classical_joker():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 双角帽
|
||||||
|
d.polygon([(60, 95), (70, 35), (80, 85)], fill=(220, 40, 40, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
d.polygon([(140, 95), (130, 35), (120, 85)], fill=(40, 80, 200, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
d.ellipse((65, 30, 75, 40), fill=(220, 40, 40, 200), outline=STROKE, width=2)
|
||||||
|
d.ellipse((125, 30, 135, 40), fill=(40, 80, 200, 200), outline=STROKE, width=2)
|
||||||
|
# 脸
|
||||||
|
d.ellipse((58, 93, 142, 177), fill=(255, 235, 200, 180), outline=STROKE, width=STROKE_W)
|
||||||
|
# 菱形眼
|
||||||
|
d.polygon([(80, 130), (86, 125), (92, 130), (86, 135)], fill=STROKE)
|
||||||
|
d.polygon([(108, 130), (114, 125), (120, 130), (114, 135)], fill=STROKE)
|
||||||
|
# 大嘴
|
||||||
|
d.arc((78, 148, 122, 175), start=0, end=180, fill=(200, 40, 40, 80), width=3)
|
||||||
|
d.arc((78, 148, 122, 175), start=0, end=180, fill=STROKE, width=3)
|
||||||
|
# 领结
|
||||||
|
d.polygon([(90, 200), (100, 195), (110, 200), (100, 215)], fill=(220, 40, 40, 200), outline=STROKE, width=2)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- 现代人物 -----------------
|
||||||
|
def modern_child():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 短发
|
||||||
|
d.chord((55, 80, 145, 115), start=180, end=360, fill=(60, 60, 60, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 脸
|
||||||
|
d.ellipse((62, 97, 138, 173), fill=(255, 225, 190, 180), outline=STROKE, width=STROKE_W)
|
||||||
|
# 大眼睛
|
||||||
|
d.ellipse((83, 128, 93, 138), fill=STROKE)
|
||||||
|
d.ellipse((107, 128, 117, 138), fill=STROKE)
|
||||||
|
# 腮红
|
||||||
|
d.ellipse((73, 145, 83, 155), fill=(255, 150, 150, 100))
|
||||||
|
d.ellipse((117, 145, 127, 155), fill=(255, 150, 150, 100))
|
||||||
|
# 嘴
|
||||||
|
d.arc((93, 150, 107, 160), start=0, end=180, fill=STROKE, width=3)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def modern_young_woman():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 长发
|
||||||
|
d.polygon([(45, 150), (50, 240), (150, 240), (155, 150),
|
||||||
|
(160, 90), (140, 80), (60, 80), (40, 90)],
|
||||||
|
fill=(80, 40, 30, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 脸(椭圆)
|
||||||
|
d.ellipse((65, 98, 135, 178), fill=(255, 225, 190, 180), outline=STROKE, width=STROKE_W)
|
||||||
|
# 眉
|
||||||
|
d.arc((80, 116, 96, 128), start=200, end=340, fill=STROKE, width=3)
|
||||||
|
d.arc((104, 116, 120, 128), start=200, end=340, fill=STROKE, width=3)
|
||||||
|
# 眼睛
|
||||||
|
d.ellipse((85, 132, 91, 138), fill=STROKE)
|
||||||
|
d.ellipse((109, 132, 115, 138), fill=STROKE)
|
||||||
|
# 嘴
|
||||||
|
d.arc((90, 152, 110, 166), start=0, end=180, fill=STROKE, width=3)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def modern_young_man():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 短发
|
||||||
|
d.chord((55, 80, 145, 115), start=180, end=360, fill=(40, 30, 20, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 脸(方下颌:圆角矩形近似)
|
||||||
|
d.rounded_rectangle((65, 100, 135, 180), radius=18, fill=(255, 225, 190, 180), outline=STROKE, width=STROKE_W)
|
||||||
|
# 眉
|
||||||
|
d.line([(78, 122), (98, 122)], fill=STROKE, width=3)
|
||||||
|
d.line([(102, 122), (122, 122)], fill=STROKE, width=3)
|
||||||
|
# 眼睛
|
||||||
|
d.ellipse((85, 133, 91, 139), fill=STROKE)
|
||||||
|
d.ellipse((109, 133, 115, 139), fill=STROKE)
|
||||||
|
# 嘴
|
||||||
|
d.line([(90, 158), (110, 158)], fill=STROKE, width=3)
|
||||||
|
# 胡茬点
|
||||||
|
d.ellipse((88, 168, 92, 172), fill=STROKE)
|
||||||
|
d.ellipse((98, 170, 102, 174), fill=STROKE)
|
||||||
|
d.ellipse((108, 168, 112, 172), fill=STROKE)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def modern_clownfish():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 鱼身
|
||||||
|
d.ellipse((45, 112, 155, 188), fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 嘴部
|
||||||
|
d.polygon([(55, 150), (40, 145), (40, 155)], fill=(255, 140, 40, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
# 眼睛(白底+黑瞳)
|
||||||
|
d.ellipse((58, 125, 74, 141), fill=(255, 255, 255, 230), outline=STROKE, width=2)
|
||||||
|
d.ellipse((62, 130, 70, 138), fill=STROKE)
|
||||||
|
# 白条纹
|
||||||
|
d.arc((75, 110, 90, 200), start=270, end=90, fill=(255, 255, 255, 200), width=8)
|
||||||
|
d.arc((100, 105, 115, 200), start=270, end=90, fill=(255, 255, 255, 200), width=6)
|
||||||
|
# 背鳍
|
||||||
|
d.polygon([(85, 112), (100, 88), (115, 112)], fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 腹鳍
|
||||||
|
d.polygon([(90, 188), (95, 208), (105, 188)], fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 尾鳍
|
||||||
|
d.polygon([(152, 150), (180, 120), (180, 180)], fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- 天文 -----------------
|
||||||
|
def astronomy_star():
|
||||||
|
img, d = new_canvas()
|
||||||
|
cx, cy = 100, 140
|
||||||
|
r_out, r_in = 60, 25
|
||||||
|
pts = []
|
||||||
|
for i in range(10):
|
||||||
|
a = -math.pi / 2 + i * math.pi / 5
|
||||||
|
r = r_out if i % 2 == 0 else r_in
|
||||||
|
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
||||||
|
d.polygon(pts, fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 中心高光
|
||||||
|
d.ellipse((94, 134, 106, 146), fill=(255, 255, 200, 200), outline=STROKE, width=1)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def astronomy_moon():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 大圆
|
||||||
|
d.ellipse((45, 80, 155, 220), fill=(240, 240, 210, 180), outline=STROKE, width=STROKE_W)
|
||||||
|
# 遮挡圆(右侧偏移实现弯月)
|
||||||
|
d.ellipse((75, 80, 165, 220), fill=(0, 0, 0, 0)) # 透明
|
||||||
|
# 用图层减法实现:在 PIL 中不好做,直接画月牙
|
||||||
|
img2 = Image.new('RGBA', (W, H), (255, 255, 255, 0))
|
||||||
|
d2 = ImageDraw.Draw(img2)
|
||||||
|
d2.ellipse((45, 80, 155, 220), fill=(240, 240, 210, 180), outline=STROKE, width=STROKE_W)
|
||||||
|
# 透明圆覆盖(用镂空 mask)
|
||||||
|
mask = Image.new('L', (W, H), 0)
|
||||||
|
dm = ImageDraw.Draw(mask)
|
||||||
|
dm.ellipse((80, 80, 165, 220), fill=255)
|
||||||
|
out = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
out.paste(img2, (0, 0), mask)
|
||||||
|
d = ImageDraw.Draw(out)
|
||||||
|
d.ellipse((45, 80, 155, 220), outline=STROKE, width=STROKE_W)
|
||||||
|
# 表面纹理
|
||||||
|
d.ellipse((85, 125, 95, 135), fill=(180, 180, 160, 130))
|
||||||
|
d.ellipse((95, 165, 102, 172), fill=(180, 180, 160, 130))
|
||||||
|
d.ellipse((75, 155, 82, 162), fill=(180, 180, 160, 130))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def astronomy_sun():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 8 道光芒
|
||||||
|
rays = [
|
||||||
|
(100, 55, 100, 80),
|
||||||
|
(100, 220, 100, 245),
|
||||||
|
(30, 150, 55, 150),
|
||||||
|
(145, 150, 170, 150),
|
||||||
|
(50, 100, 68, 118),
|
||||||
|
(132, 118, 150, 100),
|
||||||
|
(50, 200, 68, 182),
|
||||||
|
(132, 182, 150, 200),
|
||||||
|
]
|
||||||
|
for x1, y1, x2, y2 in rays:
|
||||||
|
d.line([(x1, y1), (x2, y2)], fill=STROKE, width=5)
|
||||||
|
# 太阳主体
|
||||||
|
d.ellipse((55, 105, 145, 195), fill=(255, 180, 40, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
# 笑眼
|
||||||
|
d.arc((73, 128, 88, 143), start=200, end=340, fill=STROKE, width=3)
|
||||||
|
d.arc((112, 128, 127, 143), start=200, end=340, fill=STROKE, width=3)
|
||||||
|
# 嘴
|
||||||
|
d.arc((85, 155, 115, 175), start=0, end=180, fill=STROKE, width=3)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def astronomy_blackhole():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 吸积盘(两层椭圆)
|
||||||
|
d.ellipse((30, 128, 170, 172), fill=(120, 60, 200, 100), outline=STROKE, width=STROKE_W)
|
||||||
|
d.ellipse((42, 134, 158, 166), fill=(200, 100, 50, 130), outline=STROKE, width=STROKE_W)
|
||||||
|
# 中心黑洞
|
||||||
|
d.ellipse((78, 128, 122, 172), fill=(0, 0, 0, 240), outline=STROKE, width=STROKE_W)
|
||||||
|
# 高光
|
||||||
|
d.ellipse((85, 134, 115, 142), fill=(255, 180, 80, 200))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- 简笔 -----------------
|
||||||
|
def minimal_dot():
|
||||||
|
img, d = new_canvas()
|
||||||
|
d.ellipse((45, 95, 155, 205), fill=(80, 180, 200, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
# 内圈虚线
|
||||||
|
d.ellipse((65, 115, 135, 185), outline=STROKE, width=2)
|
||||||
|
# 笑脸
|
||||||
|
d.ellipse((82, 138, 88, 144), fill=STROKE)
|
||||||
|
d.ellipse((112, 138, 118, 144), fill=STROKE)
|
||||||
|
d.arc((85, 155, 115, 170), start=0, end=180, fill=STROKE, width=3)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_venus():
|
||||||
|
img, d = new_canvas()
|
||||||
|
d.ellipse((60, 80, 140, 160), fill=(220, 80, 140, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
d.line([(100, 160), (100, 230)], fill=STROKE, width=8)
|
||||||
|
d.line([(80, 200), (120, 200)], fill=STROKE, width=8)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_mars():
|
||||||
|
img, d = new_canvas()
|
||||||
|
d.ellipse((40, 120, 120, 200), fill=(60, 120, 220, 150), outline=STROKE, width=STROKE_W)
|
||||||
|
d.line([(115, 120), (155, 80)], fill=STROKE, width=6)
|
||||||
|
d.line([(135, 75), (158, 75)], fill=STROKE, width=6)
|
||||||
|
d.line([(158, 75), (158, 98)], fill=STROKE, width=6)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_cross():
|
||||||
|
img, d = new_canvas()
|
||||||
|
# 双色叉:先画浅红底,再深色描边
|
||||||
|
d.line([(55, 95), (155, 225)], fill=(200, 40, 40, 130), width=14)
|
||||||
|
d.line([(155, 95), (55, 225)], fill=(200, 40, 40, 130), width=14)
|
||||||
|
d.line([(55, 95), (155, 225)], fill=STROKE, width=4)
|
||||||
|
d.line([(155, 95), (55, 225)], fill=STROKE, width=4)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
THEMES = [
|
||||||
|
{
|
||||||
|
'theme_id': 'classical', 'theme_name': '古典宫廷',
|
||||||
|
'description': '王子/皇后/国王/宫廷小丑,传统扑克牌',
|
||||||
|
'items': [
|
||||||
|
('J', 'prince', classical_prince, '王子'),
|
||||||
|
('Q', 'queen', classical_queen, '皇后'),
|
||||||
|
('K', 'king', classical_king, '国王'),
|
||||||
|
('joker', 'jester', classical_joker, '小丑'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'theme_id': 'modern', 'theme_name': '现代人物',
|
||||||
|
'description': '小孩/女青年/男青年/小丑鱼,日常生活',
|
||||||
|
'items': [
|
||||||
|
('J', 'child', modern_child, '小孩'),
|
||||||
|
('Q', 'young_woman', modern_young_woman, '女青年'),
|
||||||
|
('K', 'young_man', modern_young_man, '男青年'),
|
||||||
|
('joker', 'clownfish', modern_clownfish, '小丑鱼'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'theme_id': 'astronomy', 'theme_name': '天文',
|
||||||
|
'description': '星星/月亮/太阳/黑洞',
|
||||||
|
'items': [
|
||||||
|
('J', 'star', astronomy_star, '星星'),
|
||||||
|
('Q', 'moon', astronomy_moon, '月亮'),
|
||||||
|
('K', 'sun', astronomy_sun, '太阳'),
|
||||||
|
('joker', 'blackhole', astronomy_blackhole, '黑洞'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'theme_id': 'minimal', 'theme_name': '简笔符号',
|
||||||
|
'description': '圆点/♀/♂/×,极简风格',
|
||||||
|
'items': [
|
||||||
|
('J', 'dot', minimal_dot, '圆点'),
|
||||||
|
('Q', 'venus', minimal_venus, '♀'),
|
||||||
|
('K', 'mars', minimal_mars, '♂'),
|
||||||
|
('joker', 'cross', minimal_cross, '×'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '用 Pillow 画 16 张预设素材 PNG,存到 media/library/,并写入 LibraryAsset 表'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
lib_dir = os.path.join(settings.MEDIA_ROOT, 'library')
|
||||||
|
os.makedirs(lib_dir, exist_ok=True)
|
||||||
|
|
||||||
|
written = 0
|
||||||
|
for theme in THEMES:
|
||||||
|
theme_dir = os.path.join(lib_dir, theme['theme_id'])
|
||||||
|
os.makedirs(theme_dir, exist_ok=True)
|
||||||
|
for role, asset_id, gen_fn, role_name in theme['items']:
|
||||||
|
img = gen_fn()
|
||||||
|
# 上移 30 让主体更靠上,再裁掉 y=150 之后(只留上半身)
|
||||||
|
img = shift_up(img, dy=-30)
|
||||||
|
img = crop_to_upper(img)
|
||||||
|
file_name = f'{asset_id}.png'
|
||||||
|
file_path = os.path.join(theme_dir, file_name)
|
||||||
|
img.save(file_path)
|
||||||
|
rel_path = f'library/{theme["theme_id"]}/{file_name}'
|
||||||
|
|
||||||
|
label = f'{theme["theme_name"]}·{role_name}'
|
||||||
|
obj, created = LibraryAsset.objects.update_or_create(
|
||||||
|
theme_id=theme['theme_id'],
|
||||||
|
asset_id=asset_id,
|
||||||
|
defaults=dict(
|
||||||
|
theme_name=theme['theme_name'],
|
||||||
|
role=role,
|
||||||
|
role_name=role_name,
|
||||||
|
label=label,
|
||||||
|
description=theme['description'],
|
||||||
|
file_path=rel_path,
|
||||||
|
file_name=file_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
verb = 'created' if created else 'updated'
|
||||||
|
self.stdout.write(f' [{verb}] {theme["theme_id"]}/{file_name} -> {label}')
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n已生成 {written} 张 PNG 素材({len(THEMES)} 套主题)\n'
|
||||||
|
f'文件目录: media/library/\n'
|
||||||
|
f'数据库表: apps_projects_libraryasset'
|
||||||
|
))
|
||||||
33
backend/apps/projects/migrations/0003_libraryasset.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-06-02 05:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0002_project_card_overrides_project_design_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LibraryAsset',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||||
|
('theme_id', models.CharField(db_index=True, max_length=50)),
|
||||||
|
('theme_name', models.CharField(max_length=100)),
|
||||||
|
('role', models.CharField(db_index=True, max_length=10)),
|
||||||
|
('role_name', models.CharField(max_length=50)),
|
||||||
|
('asset_id', models.CharField(max_length=50)),
|
||||||
|
('label', models.CharField(max_length=100)),
|
||||||
|
('description', models.CharField(blank=True, max_length=255)),
|
||||||
|
('file_path', models.CharField(max_length=255)),
|
||||||
|
('file_name', models.CharField(max_length=100)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['theme_id', 'role', 'asset_id'],
|
||||||
|
'unique_together': {('theme_id', 'asset_id')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -104,3 +104,28 @@ class CardLayer(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['card_key', 'z_index']
|
ordering = ['card_key', 'z_index']
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryAsset(models.Model):
|
||||||
|
"""预设素材库(与 Project 无关,全局共享)
|
||||||
|
主题分类:classical/modern/astronomy/minimal ...
|
||||||
|
角色:J / Q / K / joker
|
||||||
|
"""
|
||||||
|
id = models.BigAutoField(primary_key=True)
|
||||||
|
theme_id = models.CharField(max_length=50, db_index=True) # classical / modern ...
|
||||||
|
theme_name = models.CharField(max_length=100) # 古典宫廷 / 现代人物 ...
|
||||||
|
role = models.CharField(max_length=10, db_index=True) # 'J' / 'Q' / 'K' / 'joker'
|
||||||
|
role_name = models.CharField(max_length=50) # '王子' / '皇后' ...
|
||||||
|
asset_id = models.CharField(max_length=50) # 'prince' / 'queen' ...
|
||||||
|
label = models.CharField(max_length=100) # '古典·王子'
|
||||||
|
description = models.CharField(max_length=255, blank=True)
|
||||||
|
file_path = models.CharField(max_length=255) # library/classical/prince.svg
|
||||||
|
file_name = models.CharField(max_length=100)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.theme_name}/{self.role_name}'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['theme_id', 'role', 'asset_id']
|
||||||
|
unique_together = [['theme_id', 'asset_id']]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from .models import Project, Asset, CardLayer
|
from .models import Project, Asset, CardLayer, LibraryAsset
|
||||||
|
|
||||||
|
|
||||||
class ProjectSerializer(serializers.ModelSerializer):
|
class ProjectSerializer(serializers.ModelSerializer):
|
||||||
@@ -52,3 +52,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer):
|
|||||||
'assets', 'layers',
|
'assets', 'layers',
|
||||||
'created_at', 'updated_at',
|
'created_at', 'updated_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryAssetSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = LibraryAsset
|
||||||
|
fields = ['id', 'theme_id', 'theme_name', 'role', 'role_name',
|
||||||
|
'asset_id', 'label', 'description', 'file_path', 'file_name',
|
||||||
|
'created_at']
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from .views import (
|
|||||||
project_list, project_detail, project_save_design,
|
project_list, project_detail, project_save_design,
|
||||||
asset_list, asset_detail,
|
asset_list, asset_detail,
|
||||||
)
|
)
|
||||||
|
from .views_library import library_list, library_themes, library_detail, library_apply
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', project_list, name='project-list'),
|
path('', project_list, name='project-list'),
|
||||||
@@ -10,4 +11,8 @@ urlpatterns = [
|
|||||||
path('<str:pk>/design/', project_save_design, name='project-save-design'),
|
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'),
|
||||||
|
path('library/', library_list, name='library-list'),
|
||||||
|
path('library/themes/', library_themes, name='library-themes'),
|
||||||
|
path('<str:project_pk>/library/<int:pk>/apply/', library_apply, name='library-apply'),
|
||||||
|
path('library/<int:pk>/', library_detail, name='library-detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
114
backend/apps/projects/views_library.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from rest_framework.decorators import api_view
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import LibraryAsset, Project, Asset
|
||||||
|
from .serializers import LibraryAssetSerializer
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def library_list(request):
|
||||||
|
"""列出所有预设素材(按主题分组)"""
|
||||||
|
assets = LibraryAsset.objects.all()
|
||||||
|
serializer = LibraryAssetSerializer(assets, many=True, context={'request': request})
|
||||||
|
|
||||||
|
# 按 theme_id 分组
|
||||||
|
grouped = {}
|
||||||
|
for a in serializer.data:
|
||||||
|
a['file_url'] = f'{settings.MEDIA_URL}{a["file_path"]}'
|
||||||
|
grouped.setdefault(a['theme_id'], {
|
||||||
|
'theme_id': a['theme_id'],
|
||||||
|
'theme_name': a['theme_name'],
|
||||||
|
'description': a['description'],
|
||||||
|
'items': [],
|
||||||
|
})['items'].append(a)
|
||||||
|
|
||||||
|
return Response(list(grouped.values()))
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def library_themes(request):
|
||||||
|
"""返回所有主题的元信息(不含具体素材项,用于渲染主题筛选器)"""
|
||||||
|
themes = LibraryAsset.objects.values('theme_id', 'theme_name', 'description').distinct()
|
||||||
|
return Response(list(themes))
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def library_detail(request, pk):
|
||||||
|
"""单个预设素材的详情"""
|
||||||
|
try:
|
||||||
|
a = LibraryAsset.objects.get(pk=pk)
|
||||||
|
except LibraryAsset.DoesNotExist:
|
||||||
|
return Response({'error': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
data = LibraryAssetSerializer(a, context={'request': request}).data
|
||||||
|
data['file_url'] = f'{settings.MEDIA_URL}{data["file_path"]}'
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def library_apply(request, project_pk, pk):
|
||||||
|
"""把预设素材应用到项目某张牌(默认:spade-J / joker-big 等)
|
||||||
|
请求体: { card_key?: 'spade-J' } 可选;不传则用预设素材的 role + 默认 spade
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project = Project.objects.get(pk=project_pk)
|
||||||
|
lib = LibraryAsset.objects.get(pk=pk)
|
||||||
|
except (Project.DoesNotExist, LibraryAsset.DoesNotExist):
|
||||||
|
return Response({'error': 'not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
card_key = request.data.get('card_key')
|
||||||
|
if not card_key:
|
||||||
|
# 默认: spade-{role} 或 joker-{which}
|
||||||
|
if lib.role == 'joker':
|
||||||
|
card_key = 'joker-big' # 默认应用到 big;用户可换
|
||||||
|
else:
|
||||||
|
card_key = f'spade-{lib.role}'
|
||||||
|
|
||||||
|
# 决定 asset_type: J/Q/K -> face_card;joker -> joker
|
||||||
|
asset_type = 'face_card' if lib.role in ('J', 'Q', 'K') else 'joker'
|
||||||
|
asset_key = card_key
|
||||||
|
|
||||||
|
# 删除该项目同 (asset_type, asset_key) 的旧记录(避免重复)
|
||||||
|
Asset.objects.filter(project=project, asset_type=asset_type, asset_key=asset_key).delete()
|
||||||
|
|
||||||
|
# 复制 library 文件到 projects/<pid>/ 下,避免污染原文件
|
||||||
|
import shutil
|
||||||
|
from time import time
|
||||||
|
project_media_dir = os.path.join('projects', str(project.id), asset_type)
|
||||||
|
full_dir = os.path.join(settings.MEDIA_ROOT, project_media_dir)
|
||||||
|
os.makedirs(full_dir, exist_ok=True)
|
||||||
|
|
||||||
|
src_path = os.path.join(settings.MEDIA_ROOT, lib.file_path)
|
||||||
|
ts = int(time() * 1000)
|
||||||
|
new_file_name = f'{asset_key}_{ts}_{lib.file_name}'
|
||||||
|
dst_rel = os.path.join(project_media_dir, new_file_name)
|
||||||
|
dst_abs = os.path.join(settings.MEDIA_ROOT, dst_rel)
|
||||||
|
shutil.copy2(src_path, dst_abs)
|
||||||
|
|
||||||
|
# 读 svg 尺寸
|
||||||
|
width = height = None
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(dst_abs) as im:
|
||||||
|
width, height = im.size
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
asset = Asset.objects.create(
|
||||||
|
project=project,
|
||||||
|
asset_type=asset_type,
|
||||||
|
asset_key=asset_key,
|
||||||
|
file_path=dst_rel,
|
||||||
|
file_name=new_file_name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'ok': True,
|
||||||
|
'asset_id': asset.id,
|
||||||
|
'card_key': card_key,
|
||||||
|
'asset_type': asset_type,
|
||||||
|
'file_url': f'{settings.MEDIA_URL}{dst_rel}',
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
BIN
backend/media/library/astronomy/blackhole.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
backend/media/library/astronomy/moon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
backend/media/library/astronomy/star.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
backend/media/library/astronomy/sun.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
backend/media/library/classical/jester.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
backend/media/library/classical/king.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
backend/media/library/classical/prince.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
backend/media/library/classical/queen.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
backend/media/library/minimal/cross.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
backend/media/library/minimal/dot.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
backend/media/library/minimal/mars.png
Normal file
|
After Width: | Height: | Size: 1009 B |
BIN
backend/media/library/minimal/venus.png
Normal file
|
After Width: | Height: | Size: 961 B |
BIN
backend/media/library/modern/child.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
backend/media/library/modern/clownfish.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
backend/media/library/modern/young_man.png
Normal file
|
After Width: | Height: | Size: 1015 B |
BIN
backend/media/library/modern/young_woman.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
@@ -5,7 +5,18 @@
|
|||||||
<button class="primary" @click="showUpload = true">+ 上传</button>
|
<button class="primary" @click="showUpload = true">+ 上传</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JQK 人物图 -->
|
<!-- 标签页 -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button :class="['tab', { active: activeTab === 'mine' }]" @click="activeTab = 'mine'">
|
||||||
|
我的素材
|
||||||
|
</button>
|
||||||
|
<button :class="['tab', { active: activeTab === 'library' }]" @click="activeTab = 'library'">
|
||||||
|
预设主题
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我的素材 tab -->
|
||||||
|
<div v-if="activeTab === 'mine'">
|
||||||
<section v-if="faceCardAssets.length">
|
<section v-if="faceCardAssets.length">
|
||||||
<h5>JQK 人物图</h5>
|
<h5>JQK 人物图</h5>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -19,21 +30,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 大小王 -->
|
|
||||||
<section v-if="jokerAssets.length">
|
<section v-if="jokerAssets.length">
|
||||||
<h5>大小王</h5>
|
<h5>大小王</h5>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div v-for="a in jokerAssets" :key="a.id" class="asset-tile">
|
<div v-for="a in jokerAssets" :key="a.id" class="asset-tile">
|
||||||
<img :src="a.file_url" />
|
<img :src="a.file_url" />
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<div class="key">{{ a.asset_key === 'big' ? '大王' : '小王' }}</div>
|
<div class="key">{{ a.asset_key === 'joker-big' ? '大王' : a.asset_key === 'joker-small' ? '小王' : a.asset_key }}</div>
|
||||||
<button class="mini ghost" @click="del(a)">删除</button>
|
<button class="mini ghost" @click="del(a)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 背面 -->
|
|
||||||
<section v-if="backAssets.length">
|
<section v-if="backAssets.length">
|
||||||
<h5>背面图案</h5>
|
<h5>背面图案</h5>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -51,6 +60,59 @@
|
|||||||
<p>还没有素材</p>
|
<p>还没有素材</p>
|
||||||
<p class="hint">点击右上「上传」按钮,添加 JQK 人物、大小王图或背面</p>
|
<p class="hint">点击右上「上传」按钮,添加 JQK 人物、大小王图或背面</p>
|
||||||
<p class="hint">JQK 只需上传上半身图,系统会自动生成中心对称的完整牌面</p>
|
<p class="hint">JQK 只需上传上半身图,系统会自动生成中心对称的完整牌面</p>
|
||||||
|
<p class="hint">或切到「预设主题」标签页快速套用 4 套主题</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预设主题 tab -->
|
||||||
|
<div v-else-if="activeTab === 'library'">
|
||||||
|
<div v-if="libraryLoading" class="loading">加载主题中…</div>
|
||||||
|
<div v-else-if="libraryError" class="empty error">{{ libraryError }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- 主题筛选 -->
|
||||||
|
<div class="theme-filter">
|
||||||
|
<button
|
||||||
|
:class="['theme-tab', { active: !selectedTheme }]"
|
||||||
|
@click="selectedTheme = null"
|
||||||
|
>全部</button>
|
||||||
|
<button
|
||||||
|
v-for="t in themes"
|
||||||
|
:key="t.theme_id"
|
||||||
|
:class="['theme-tab', { active: selectedTheme === t.theme_id }]"
|
||||||
|
@click="selectedTheme = t.theme_id"
|
||||||
|
>{{ t.theme_name }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedThemeObj" class="theme-desc">
|
||||||
|
{{ selectedThemeObj.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredGroups.length === 0" class="empty">
|
||||||
|
<p>该主题没有素材</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="g in filteredGroups" :key="g.theme_id" class="lib-group">
|
||||||
|
<h5 class="lib-group-title">
|
||||||
|
<span>{{ g.theme_name }}</span>
|
||||||
|
<span class="lib-count">{{ g.items.length }} 张</span>
|
||||||
|
</h5>
|
||||||
|
<div class="grid">
|
||||||
|
<div
|
||||||
|
v-for="lib in g.items"
|
||||||
|
:key="lib.id"
|
||||||
|
:class="['asset-tile', { applying: applyingId === lib.id }]"
|
||||||
|
@click="applyLib(lib)"
|
||||||
|
:title="`点击应用到当前牌位:${targetCardKey}`"
|
||||||
|
>
|
||||||
|
<img :src="`/media/${lib.file_path}`" />
|
||||||
|
<div class="meta">
|
||||||
|
<div class="key">{{ lib.role_name }}</div>
|
||||||
|
<div class="lib-action">{{ applyingId === lib.id ? '应用中…' : '点击套用' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetUploadDialog
|
<AssetUploadDialog
|
||||||
@@ -62,7 +124,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useProjectStore } from '@/stores/projectStore'
|
import { useProjectStore } from '@/stores/projectStore'
|
||||||
@@ -70,7 +132,9 @@ import AssetUploadDialog from '@/components/AssetUploadDialog.vue'
|
|||||||
|
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const showUpload = ref(false)
|
const showUpload = ref(false)
|
||||||
|
const activeTab = ref('mine')
|
||||||
|
|
||||||
|
// ----- 我的素材 -----
|
||||||
const assets = computed(() => store.project?.assets || [])
|
const assets = computed(() => store.project?.assets || [])
|
||||||
const faceCardAssets = computed(() => assets.value.filter(a => a.asset_type === 'face_card'))
|
const faceCardAssets = computed(() => assets.value.filter(a => a.asset_type === 'face_card'))
|
||||||
const jokerAssets = computed(() => assets.value.filter(a => a.asset_type === 'joker'))
|
const jokerAssets = computed(() => assets.value.filter(a => a.asset_type === 'joker'))
|
||||||
@@ -91,6 +155,75 @@ async function del(a) {
|
|||||||
if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message)
|
if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- 预设主题 -----
|
||||||
|
const library = ref([])
|
||||||
|
const libraryLoading = ref(false)
|
||||||
|
const libraryError = ref('')
|
||||||
|
const selectedTheme = ref(null)
|
||||||
|
const applyingId = ref(null)
|
||||||
|
|
||||||
|
const themes = computed(() => library.value.map(g => ({
|
||||||
|
theme_id: g.theme_id,
|
||||||
|
theme_name: g.theme_name,
|
||||||
|
description: g.description,
|
||||||
|
})))
|
||||||
|
const selectedThemeObj = computed(() =>
|
||||||
|
selectedTheme.value ? themes.value.find(t => t.theme_id === selectedTheme.value) : null
|
||||||
|
)
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
if (!selectedTheme.value) return library.value
|
||||||
|
return library.value.filter(g => g.theme_id === selectedTheme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前牌位 key(点击预设时套用到哪张牌)
|
||||||
|
const targetCardKey = computed(() => store.currentCard || 'spade-A')
|
||||||
|
|
||||||
|
async function loadLibrary() {
|
||||||
|
libraryLoading.value = true
|
||||||
|
libraryError.value = ''
|
||||||
|
try {
|
||||||
|
const r = await axios.get('/api/projects/library/')
|
||||||
|
library.value = r.data || []
|
||||||
|
} catch (e) {
|
||||||
|
libraryError.value = '加载预设失败: ' + e.message
|
||||||
|
} finally {
|
||||||
|
libraryLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLib(lib) {
|
||||||
|
if (!store.project) {
|
||||||
|
ElMessage.warning('请先创建/打开项目')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyingId.value = lib.id
|
||||||
|
try {
|
||||||
|
const cardKey = targetCardKey.value
|
||||||
|
// 根据 lib.role 决定 asset_type
|
||||||
|
const assetType = (lib.role === 'joker') ? 'joker' : 'face_card'
|
||||||
|
const r = await axios.post(
|
||||||
|
`/api/projects/${store.project.id}/library/${lib.id}/apply/`,
|
||||||
|
{ card_key: cardKey }
|
||||||
|
)
|
||||||
|
ElMessage.success(`已套用「${lib.label}」到 ${cardKey}`)
|
||||||
|
await store.refreshAssets()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('套用失败: ' + (e.response?.data?.error || e.message))
|
||||||
|
} finally {
|
||||||
|
applyingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
if (val === 'library' && library.value.length === 0) {
|
||||||
|
loadLibrary()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (activeTab.value === 'library') loadLibrary()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -98,16 +231,37 @@ async function del(a) {
|
|||||||
.asset-panel h5 { margin: 0 0 8px 0; font-size: 12px; color: #aaa; }
|
.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; }
|
.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; }
|
button.primary { background: #e94560; color: #fff; border: none; border-radius: 3px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
|
||||||
|
.tabs { display: flex; gap: 4px; margin-bottom: 12px; border-bottom: 1px solid #16213e; }
|
||||||
|
.tab { flex: 1; background: transparent; color: #888; border: none; border-bottom: 2px solid transparent; padding: 6px 0; font-size: 12px; cursor: pointer; }
|
||||||
|
.tab.active { color: #e94560; border-bottom-color: #e94560; }
|
||||||
|
|
||||||
section { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #16213e; }
|
section { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #16213e; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
||||||
.asset-tile { background: #16213e; border-radius: 4px; overflow: hidden; }
|
.asset-tile { background: #16213e; border-radius: 4px; overflow: hidden; cursor: default; }
|
||||||
|
.asset-tile.applying { opacity: 0.5; }
|
||||||
|
.lib-group .asset-tile { cursor: pointer; transition: all 0.15s; }
|
||||||
|
.lib-group .asset-tile:hover { background: #e94560; transform: translateY(-2px); }
|
||||||
.asset-tile img { width: 100%; height: 70px; object-fit: contain; background: #fff; }
|
.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 .meta { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; }
|
||||||
.asset-tile .key { font-size: 11px; color: #ccc; }
|
.asset-tile .key { font-size: 11px; color: #ccc; }
|
||||||
|
.asset-tile .lib-action { font-size: 10px; color: #888; }
|
||||||
|
|
||||||
.mini { font-size: 10px; padding: 2px 6px; border: none; border-radius: 3px; cursor: pointer; }
|
.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 { background: transparent; color: #888; border: 1px solid #444; }
|
||||||
.mini.ghost:hover { color: #e94560; border-color: #e94560; }
|
.mini.ghost:hover { color: #e94560; border-color: #e94560; }
|
||||||
|
|
||||||
.empty { text-align: center; padding: 20px 8px; color: #777; }
|
.empty { text-align: center; padding: 20px 8px; color: #777; }
|
||||||
.empty p { margin: 4px 0; font-size: 12px; }
|
.empty p { margin: 4px 0; font-size: 12px; }
|
||||||
.empty .hint { font-size: 11px; color: #555; line-height: 1.4; }
|
.empty .hint { font-size: 11px; color: #555; line-height: 1.4; }
|
||||||
|
.empty.error { color: #e94560; }
|
||||||
|
.loading { text-align: center; padding: 20px; color: #888; font-size: 12px; }
|
||||||
|
|
||||||
|
.theme-filter { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.theme-tab { background: #16213e; color: #aaa; border: none; padding: 4px 8px; border-radius: 3px; font-size: 11px; cursor: pointer; }
|
||||||
|
.theme-tab.active { background: #e94560; color: #fff; }
|
||||||
|
.theme-desc { font-size: 11px; color: #888; margin-bottom: 10px; line-height: 1.4; }
|
||||||
|
|
||||||
|
.lib-group { margin-bottom: 16px; }
|
||||||
|
.lib-group-title { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px 0; font-size: 12px; color: #ccc; }
|
||||||
|
.lib-count { font-size: 10px; color: #777; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||