From 5ca000b8ab104cb50f36840866fad4c9eaf20d42 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 2 Jun 2026 14:39:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A2=84=E8=AE=BE=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=BA=93=20-=204=20=E5=A5=97=E4=B8=BB=E9=A2=98=20=C3=97=204=20?= =?UTF-8?q?=E5=BC=A0=E5=9B=BE=20=3D=2016=20=E5=BC=A0=20PNG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主题: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//joker 或 face_card 下,作为该牌位的素材 - AssetPanel 加 tab 切换:「我的素材」+「预设主题」,主题可筛选、点击套用到当前牌 - 修复 generate_card_png 的 joker asset 匹配 bug:which 应该是 card_key(含前缀)才能匹配 asset_key 设计要点: - 预设素材只画上半身(y=0~150),下半留空,让系统的'自动对称'流水线正确工作 - 预设素材 PNG 200×300,深色描边 + 半透明浅色填充,在任意牌面背景上叠加都清晰 --- backend/apps/exports/utils.py | 3 +- .../management/commands/seed_library.py | 428 ++++++++++++++++++ .../projects/migrations/0003_libraryasset.py | 33 ++ backend/apps/projects/models.py | 25 + backend/apps/projects/serializers.py | 10 +- backend/apps/projects/urls.py | 5 + backend/apps/projects/views_library.py | 114 +++++ backend/media/library/astronomy/blackhole.png | Bin 0 -> 1152 bytes backend/media/library/astronomy/moon.png | Bin 0 -> 1203 bytes backend/media/library/astronomy/star.png | Bin 0 -> 1282 bytes backend/media/library/astronomy/sun.png | Bin 0 -> 1247 bytes backend/media/library/classical/jester.png | Bin 0 -> 1670 bytes backend/media/library/classical/king.png | Bin 0 -> 1622 bytes backend/media/library/classical/prince.png | Bin 0 -> 1450 bytes backend/media/library/classical/queen.png | Bin 0 -> 1808 bytes backend/media/library/minimal/cross.png | Bin 0 -> 1182 bytes backend/media/library/minimal/dot.png | Bin 0 -> 1297 bytes backend/media/library/minimal/mars.png | Bin 0 -> 1009 bytes backend/media/library/minimal/venus.png | Bin 0 -> 961 bytes backend/media/library/modern/child.png | Bin 0 -> 1235 bytes backend/media/library/modern/clownfish.png | Bin 0 -> 1599 bytes backend/media/library/modern/young_man.png | Bin 0 -> 1015 bytes backend/media/library/modern/young_woman.png | Bin 0 -> 1370 bytes frontend/src/components/AssetPanel.vue | 244 ++++++++-- 24 files changed, 815 insertions(+), 47 deletions(-) create mode 100644 backend/apps/projects/management/commands/seed_library.py create mode 100644 backend/apps/projects/migrations/0003_libraryasset.py create mode 100644 backend/apps/projects/views_library.py create mode 100644 backend/media/library/astronomy/blackhole.png create mode 100644 backend/media/library/astronomy/moon.png create mode 100644 backend/media/library/astronomy/star.png create mode 100644 backend/media/library/astronomy/sun.png create mode 100644 backend/media/library/classical/jester.png create mode 100644 backend/media/library/classical/king.png create mode 100644 backend/media/library/classical/prince.png create mode 100644 backend/media/library/classical/queen.png create mode 100644 backend/media/library/minimal/cross.png create mode 100644 backend/media/library/minimal/dot.png create mode 100644 backend/media/library/minimal/mars.png create mode 100644 backend/media/library/minimal/venus.png create mode 100644 backend/media/library/modern/child.png create mode 100644 backend/media/library/modern/clownfish.png create mode 100644 backend/media/library/modern/young_man.png create mode 100644 backend/media/library/modern/young_woman.png diff --git a/backend/apps/exports/utils.py b/backend/apps/exports/utils.py index 25722b6..b76409d 100644 --- a/backend/apps/exports/utils.py +++ b/backend/apps/exports/utils.py @@ -430,9 +430,10 @@ def generate_card_png(project, card_key, resolution='standard'): # 3. 主体内容 if card_key.startswith('joker-'): + # which 是去掉前缀的 'big'/'small',但 asset_key 含 'joker-' 前缀 which = card_key.split('-', 1)[1] # big / small 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 asset = load_image_safe(p) if p else None break diff --git a/backend/apps/projects/management/commands/seed_library.py b/backend/apps/projects/management/commands/seed_library.py new file mode 100644 index 0000000..0a8e10b --- /dev/null +++ b/backend/apps/projects/management/commands/seed_library.py @@ -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' + )) diff --git a/backend/apps/projects/migrations/0003_libraryasset.py b/backend/apps/projects/migrations/0003_libraryasset.py new file mode 100644 index 0000000..d478e42 --- /dev/null +++ b/backend/apps/projects/migrations/0003_libraryasset.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/projects/models.py b/backend/apps/projects/models.py index 4aa393e..6b658af 100644 --- a/backend/apps/projects/models.py +++ b/backend/apps/projects/models.py @@ -104,3 +104,28 @@ class CardLayer(models.Model): class Meta: 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']] diff --git a/backend/apps/projects/serializers.py b/backend/apps/projects/serializers.py index a801cea..6fd846c 100644 --- a/backend/apps/projects/serializers.py +++ b/backend/apps/projects/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from django.conf import settings -from .models import Project, Asset, CardLayer +from .models import Project, Asset, CardLayer, LibraryAsset class ProjectSerializer(serializers.ModelSerializer): @@ -52,3 +52,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer): 'assets', 'layers', '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'] diff --git a/backend/apps/projects/urls.py b/backend/apps/projects/urls.py index 482446f..e05d86a 100644 --- a/backend/apps/projects/urls.py +++ b/backend/apps/projects/urls.py @@ -3,6 +3,7 @@ from .views import ( project_list, project_detail, project_save_design, asset_list, asset_detail, ) +from .views_library import library_list, library_themes, library_detail, library_apply urlpatterns = [ path('', project_list, name='project-list'), @@ -10,4 +11,8 @@ urlpatterns = [ path('/design/', project_save_design, name='project-save-design'), path('/assets/', asset_list, name='asset-list'), path('/assets//', asset_detail, name='asset-detail'), + path('library/', library_list, name='library-list'), + path('library/themes/', library_themes, name='library-themes'), + path('/library//apply/', library_apply, name='library-apply'), + path('library//', library_detail, name='library-detail'), ] diff --git a/backend/apps/projects/views_library.py b/backend/apps/projects/views_library.py new file mode 100644 index 0000000..528d1f1 --- /dev/null +++ b/backend/apps/projects/views_library.py @@ -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// 下,避免污染原文件 + 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) diff --git a/backend/media/library/astronomy/blackhole.png b/backend/media/library/astronomy/blackhole.png new file mode 100644 index 0000000000000000000000000000000000000000..73f6039303833c1d18e6cf70696f64645f0ba495 GIT binary patch literal 1152 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxN9xO=)dhE&XXd)Kz_wi`o3 zpk~B@sX-OY;=3IBCY*Zik!x=0@=E4VZ|rZGbu}$94Xz1GH)w_Pg>VR_bSb$T&)NT7 ziuqfT@3}MEpG*?kdJ||a)qqXb?+@h(SL*iv-}mhT&*20+W#a{{`oBVsnDjeH^(joL z_;n!2x_w^zqo^BjI&9l!bh`}*#m^~a1#1bjDJ zOIUY&W}1_oweVc0lFGChwXBIZL}K;AO%dC4 z+cu_53jPzcJ56Gxlc()GXVrx^xl)|T?^M=RPF*f~K0MjX^xE|QK;pTjuI-fNvkpht zc0HaxeR2K%-%s!Rt`y4o{P}2wos{>>g*nl@$`)PG+^?ohY!!Q)6?$cLwBWbQK8epk zQ5vqfXZBjW^F8ng=)qgIVVD7E*|RG7{J7| zQ|z(bi76_LENsG7}Z>UfHTra1HE z$F@SJE7!gI>r`*Me0k|a>q+y?T8|b=t$1MJC#>4Hr*Hf72lp=4<-9B2|Hvv^LDAaY z>V)MXjUQjKN~*8z6RDM%KY7CHZcF8pbu2qYN}d!P(YRe$sj@MyAhJ{;d3jiHynf$4 z<(uhNT_U9{kw+#j);n&cKj(@x#~KHP3n>b!`yD2vJ=2a>xNhFiv@R#a>c!oge=YpI z^-n$7l^AhF&_#v!maUw-UnDc<&y@wAJtjSNyK%_#GnIxMa&Z7U4>a_s7_l%p~em-IZo@Nu%}>KiAb z=AYdiregT`L(V-mrj>7BzFX-sX7Ne~y`L>Bf7cueIo=ra zS+eyb@6OI`6d9pT94?-?@K(`98j94g6AvqZZwq?x5E5NM_>~ z^$8~zTe$1ga-=N!+fsY_Bg>|Ps`@w9zJKxl-rrBbf45$KdAWUYU40neEx*JsHI8=T z$BVDAC{H=fv7}gLZ+t`c;~gp@6W6Fua6G)jtZ#YCrnwGg$2MNy{qdT`wYH>LTR5Mb zviMe$__Zh5S3}9tUxC@T*Y(NIyrkyN(|86(uCxvR6=gUqe?Npr*ebi8C;-C0ewkLES zOJr8rs80ItCEa0hPWa^hOKd@>cdgm=g)7`llX>UUXT?{RUX}7bv~yXX1-+~zS&C0NB(VY}T-W4*O$7VF$S1U=4v z+2QB8;|iC2$I{Sv)tf=uB?`)O_N+L)`eNVbI4Aa?&0AhAR*A}L7VWJ*o5fio=foPc`MZT1r;V!<+O6ZQf%Q z#<}YZmo�i_9na+&A%{nucWZ{-){c=L4k-8!TAnm<2&%YR*1@%!6Ze!lZE%jPRA z)>(h;*u`H}EUGt*vVS?a1-`eB>ryCEnaGjCo*n;3Npik}LY9K^dwV18#&3$j>`c|d z9_%;s1>Dpo$gbIY{$bsTAIo@CzLdW8*8i7NsM9=ueL>?Dw>t~wcPlu{sGhjISI+ci zWSfK%x3OaaKT{)HqeHWUSrNa=HcjS_9VcbH8r=Ps@v!tM31kRxm~gO2u`uyMRV{E- zxS)V6yz|iO>-YCp{dxS@Ia&BbtK6a$`_(4xVA*8Y=dRT02~yl4=J8jt(D+~YIUc{| z{_<}B3-(WFymRXL`TBpQ@7~uq#P~416zH^g^y%W_x$2x7XDD1qXyRG0uRZCcS)YM$ z$~EoC^LG<^3NC*Pi9I;$>Xybc6(>JT5>RpxdRXHt%^~BcAiJmj|Lc6)I=;t(94@>m z-`xG8!dIv5n^em6p~JJoL8(HoW#;3KW3>|)Ig(=3Jk-rz8T!hfc=LNghF{}1c}1p1 zTTYfm>_(@Iw13+@5y`0(RTCg0&r)4U!|wI}829g4<{WC4Y6mji)78&qol`;+0R5}g A0ssI2 literal 0 HcmV?d00001 diff --git a/backend/media/library/astronomy/star.png b/backend/media/library/astronomy/star.png new file mode 100644 index 0000000000000000000000000000000000000000..20a6d115d0d3c9fd6b785f12d1d3a0e89a388de2 GIT binary patch literal 1282 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxN9ob+^Y45^s&_Kt7gWiJu7 z3m+0br1>Y^kWP}_u;IAXjes_fGd7M2{5p#!OnH0bYw_xH69raWRtdfP_2<1!!tr`Q zgNOllE2^u1hgS3a`FQ^9{zhx2O9@{CTyH5VOi)nh4KTf>qR=Ze!;FJNg~Q|URVUGv z{tgO`Afy$Yy?9GxDW^yBzuNzgliwPCXm1EvVztHJfhmOFah1D+)RMDVK%qriM-&ts zSG74@Ra6koiF|WwjTwgrbIZb5CZP_q)i#Wo6%$wMeC6~=t#)3}HAPjSH`w)+(1a$L zzmEkcG+cGOa7u83Q=d*&ZkR1oP^YVWa|6rLvs%s$O*(0!%lQ~r9!TnUc3|4>bZgBY z7L|an0hOnO1v~^M^muJ?xV2^v%aR4gQ7XA%kxZ8uLsjG%Id^1)PApn`FkitVPrH!)LB*Y}&|S{$T5bh!44JucQoQ7DlaEYyTl*Z-xJ| zbB7GMtv_tdS~Tej|8dg-gG1pHw#K)31)aN+X^|G|lav@AbnQx__tjL^$Q#qA81oAB zMTSq_nHoRo{FOrQ()Fi3b+^PkpT)LP>h89P??C2EHlE{aUZ1>ssc3JW)QY9M%#Wxj zZ;9y_OMF}C+Z@&#eb{Hq(+Ov|KYo7hAOCLuzpt;i|IhCAwqWD+$kx5Sx!?-V$rHL) z_bRI2^)ij*c7Hixg;ew0qgg+7u3sxS^X&a;TeXK3IY0XE=qc`8ulBiVnBs8au4F~&v~#>_9V+wZmB zW@B7=XYu`eu}?1E-`SibKS@o&ddd%xhZEo5K4h07FF#|I!qx}#ZMW6jWzzR%)x1-+ zFM+wz|RgV8xdko0b1~nBQ3E z%6c**|7yk4-&K3d3$*WiQ$MWheqHR%#OU`+zHz%Be=*IWKG@MLLscd{OnB{0vu_IO zCuMuw&!^^3nlJXc<=d;CTDNt(z?xEB~fo{Y`!?+v5}Wj@YUAvfkX$q12k}Z@Q7+Gy9!*^yPcz oZ?{VHOnk;lOnE?UHPhScm=9`i%zBi&XeY>QPgg&ebxsLQ0Bnj8l>h($ literal 0 HcmV?d00001 diff --git a/backend/media/library/astronomy/sun.png b/backend/media/library/astronomy/sun.png new file mode 100644 index 0000000000000000000000000000000000000000..57f220c2733f454337d84ff4966eb6301bdced19 GIT binary patch literal 1247 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 Po7WSL60uB|(6oO6;|SiI literal 0 HcmV?d00001 diff --git a/backend/media/library/classical/king.png b/backend/media/library/classical/king.png new file mode 100644 index 0000000000000000000000000000000000000000..e2e0f4f0ac836b529ff69c0c2b807b49c07b9cd8 GIT binary patch literal 1622 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 VYRt*b*}MQ`q^GN&%Q~loCIGx7(lY=6 literal 0 HcmV?d00001 diff --git a/backend/media/library/minimal/dot.png b/backend/media/library/minimal/dot.png new file mode 100644 index 0000000000000000000000000000000000000000..9cb8c6fdfd2a431fa3ca58286968701a0dbad9c8 GIT binary patch literal 1297 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxN9-0*a945^s&_U^`_$7Ui9 zf$EdJG%u|_`M>^}*_-VVTW?;pm^U$#Ynq0QK!wHfZLUGbL>)Yr+`k7jfJET&w107V zg4^@YKlkt5zgwq!f28xgH+{(!Q!*ZLq#RJ#r#z#IWs*u)^^s3fjWPnqp1!aDcX#*M z%j@UcfBQ2p;G3XFfyDYF0)o8(^OPqv7(}^z%+hhH>~YX)Eld$ve?)1DQR5x~xBkF4 zy{;O!SvD#6O*~YgCNi-`b%N)i2;o!7|7t!wUuNJfb6?i~{QTEj(%62c1*C94o_RCn z<6iy7GbY(nB8qZnSFz2kytd@Mv-jQ;s zc*0tXG`ss1PxqwEd?LFpGfklIP5I3V!I@9zJXsI)W7_!|-@88LJ(|l-%BA)16}OuD zrGUr#)6%e4&94>vf+WtrabJ79@@nQLk4thlCKle2K4ZH5Zj$u(Ga{a+nsdG{Ol7;e_kO?Jn!wwl zH?130>e$ozZ}!*wDooIwaP9l3bFJ?`U+j>`W$L{K< z+s*o==9q4;u3huGxail{&&S_voawm0K_NgvKtq5-goA~Pg^87^k*U$4(E%d-ebwKj zYtrF4f*dK_DYdzqSKt3tQsis(#Ow)=Q0BLr8Q&hq3!nHuXF}bxpB3r?8WQi^;k~;+TZcDYu0*XwQl+vvHqA~rT@K& zys{o&&Q8d7|MYQ2b&}@axV09GbeQjL`PF>;pGaP{@ZX5Ne>Y2cZ&}}_ySrrjzIp#A zrsoG^$>7zP)$rzxy6>-D&-&A8dA?<*+EV`v_~GFq=?qug5fxMUO00=hz0ZUOMz* z;tWeQ{v}7Jwk>uOl02^Y^36oO3Az_2evv!%)~@eY*vE;NSTDW0n*IK{q^|5o``go9 zHoKp4*yh6fpj2gV%cgtNS6KMXmC+}$n4pUCJ++^t*C)LWULjn{3Np~s)z4*}Q$iB} DbxACH literal 0 HcmV?d00001 diff --git a/backend/media/library/minimal/mars.png b/backend/media/library/minimal/mars.png new file mode 100644 index 0000000000000000000000000000000000000000..0da8db887ea908e642eea0c20ff1258cc144de65 GIT binary patch literal 1009 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxLp@9=bS45^s&_O5l&Z37X9 zz|-6wllgqV?O$W^>QV~xIfZSLW=vEFQtCKwz5Lq^mbP$)Ho1>aS%LcTf`(&$YxNuI zmQJ~s_3z*3pLhQL{eJ5g(}MK7;%D|~pWs;CDayjd!opW#PZBZ7DkN)5i-yHjgpb!UaeB$T=1@^6T%$fA;mF2b2PVPeWlWq-GW~)c438@2IH;-j zJ?BVCdQ>sbK~1%)JdF(B%wQt6!6YG!d zEUOLNH*2|?aK(e!QQ7x5{Eypg@lA1p+j{+VrOeL_JCf}mGQWH{_g>zMx>nwlYzh1O z`|580GT%3+uIB!y&rUD>cQ{;Y`{*H1&8j@5u7&GKoQ&zB3KOx3Uld&wY+E;~OxZVA z>G(yX9^HizzPBu0dloM@xYnjL$!r-<%9PN@VyivvmvN^Y0kf)u|89QdQ#fVa?)LWC zfj+_~atdQQmaNTWnrYZ~^O)hH>yhF8H}h6yKi+tCRsIhLr6YxklOFTRdMvTfb17Q0 zkhxRLeM{lDgc}ZT_Mbg2*v6ys=vvmvu-6jKY(lnuFCN=nDrmeBHFLf)ulR|eFa8%_ zJYJu>!eNcvvzHmptec)(zHD>FtVAK;_Uje5wO1ccKmBuqEUy^H76kzbTY=lFOLI0i zu`#XX03{@4_D6p<_7;0=a9E()=+Nk}z(FBEK|n)*Lj)`hOlM3`VS^%t)7y97t6pDG za{A}TvO6c48>fL2;zi~{zR2T0l`|ABbU7rnIpl0Um9xHyok^ERwxhl6*}`oLnLdWx zu#pA2a9!uVE*JUw+{3I%qK5W(^8t+{KLmZ@PT}tjxc9Z&6J&*_tDnm{r-UW|Y|3z# literal 0 HcmV?d00001 diff --git a/backend/media/library/minimal/venus.png b/backend/media/library/minimal/venus.png new file mode 100644 index 0000000000000000000000000000000000000000..6a7b91f796efe53c6fd866612b7882e264e7a8e8 GIT binary patch literal 961 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxLpcX+xuhE&XXdv{~rZ3Btc zizdn8`EkGg^-n!I`+8q)pbDc@*gxM>lX73qGd|vN@`bRbWz}u=dO5j9)*ZFE%cjFIz3!{9My&!nDoH zD>FD*xHdL!xy`ovc=+2I8RzYh8yg+?8yysl1a7akW52!TjKhNI4hs|nG>))7`jvb} z@sE{?o`8g+fCdK(7gJ*sSPGr#6r=F=cI^7S;Wl@7&YZtx`{QG*Ov)@wtYBlWE7qQS zcwHW7jXscXCUASrthb>R3KyIe{(rb|!Dh=MX3ss^#>X`)<}G5LIcZxmcIPGI~y2SjuShQWBxQRkh@y_^H%u} z+r?TL+U~vz+%2EZOpGwiQ=Tw)omyCLDo09N_Ijtf6pica`g2wu<493o$dN3|6lpoh zH2LM;*=bj2_$!q2J)M@|maotKxQwZ?KqFh=SP{#n$q_Ca`5JR9!~)m&D@b#>34B+d zz%0dBH)m5R3L^)M7pNBk`J5o>lp;ha}L}Wf5Hzk-P6_2Wt~$(69A3r BQ2zh` literal 0 HcmV?d00001 diff --git a/backend/media/library/modern/child.png b/backend/media/library/modern/child.png new file mode 100644 index 0000000000000000000000000000000000000000..34adb89e40f91d6780ca9c4960d6720b17c9c0bf GIT binary patch literal 1235 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxN9%<^<`45^s&_O4;kZ8wpI zM61cdE1vKcF^Ky;)$%F74cih4VS zMJGEOeM8G9F9p^_8N)WQi#+cpajK~I7_$c| z&rmGTNRK>~mDH=Tkr%!^D#R z49_14xXle{JE#2fsn7%qne8#L*YCgfcIiudQPEXm>v-g$MgG-Ng$+|36c*ktKEC*) zaO-oG34ZGutM&!lo7{Zs=2^ytS5zm+9{%y?Z6ALvCt9PFqK3B$W!p02o=gAl4|IctxP%KfnnE&=%;l_VjB?=o{ z6efJge#O7?w;QLa|HG*K`45DbJbin5XSrbH?|-|hbL&r;zIc0YL*tgJZ?REJqQwhe zzr8=h&7O0~qB}<;cXdSP&#z``WNCD0a!_zon4mDBtLXioDfbk!_SU}RT(W8D(Ve9q zx81XQ&$*;=r)yL;h`oN#l_e9eb%*lwCpH1K{d>Io{Ci8egUlZjr}c&RZD@4h1Dduw zLi_L6@7MDm-~au&|Gs@J->aP8)g}woPyDb-d3mTLYva@zp!u>vYKEz zP3-DLCCl6YIU2glWG6ecK5XzKnNx6@+3 zx`2m($1#zUfA<{>+_yhq)Ba`0O&V3!bUx82obo3BO7JNerpUtMX?Yjd_C8K^yjQpN z$FH@&rT?D08XcFl=|O7XzP}HbEcWeE_5Ph*wWM;{i5mG^x9ks^$IS@7eg5aHS-(FV z`cmPqVC@?8>CD|<_r;IfHp=8o@-ejhraYl(`=9)eZJhnT_Vn*m@a@lJvJ6mJ+nH>; zy;A&)?dExJ-}PIZ;!tr=d8F}NWc8)v6J$S5(H7m1-Dkt}^2@FxS5`PH%=HxxUaZ$> z^6$xY_al>vb)QUG)#KnN(`9f=_(b1))qQHSww9mq>9JxdY6!>@a*JhpsUniNMR9`g qaiy+rEJceFSQv@O)-+L4@bVD9=q&dMTMA^r=6kyOxvXb7$ULq`Y&{fPUzF4vCOwyd6C_$)>(6>45YsLxqGo z%}VxJAw|my6!m>HT0iNV=To9>8?MFVL zqD?$SO|t}J5*}I~N8`Dh5LsYSjQ#}$(ue%Mtna1Cxo(8u7q|$o`Rt51S;waWb1xoS zXX4wM%v{K^;{=SW0_skVE8wDnMxk+Z1J1DF#6nO|&>yQ`BrS6%8fA-B53>>#qu=Jm z)yeYS0f#C(hW1r)73UT4xQ42L>8&GY>AEvwR{|XNk%#?f#C~-r_?R{bZ=u0AQxVd; zy5rXvbK~%=V=1A#-(opPT(@w~`tpLgV7PbgOnvTDo$38vorTO}#-i3d?*oWnuX7gb zt9Mw_3NE|y&s&-rz>4SA{&KC1T>vrAnh{)n32)I*haP!xdv!lAuI;NErPC=WqGNno z$%Y%I&|ap$Jhb(tZOUKmEQw*8axjR5as)XLLkl zQn8Ty`2x8;*jE>*;p8g4TohUuIl{3RK6QWV#Zxw8L9z+v)Wk|N^69tLfkO|_e%zA+ zJ9^h3V#C9&R_rhC7C4pLN*HupZ{7*Nbe)*I@&4y$v{@FKwUX?(=`v$Vp;?Fletsj= z0~Xefsb623!6p{gv8D&(JWSe#$Nhf~L%iCVQobd<9(|<$uH~EL?ot{UeUhi{uk>n0?cR_s^~xVDTU)P6jOtiF*))BE} zNPqvyT;ynjGp0D|Q+Zz#Ayq!n>@9w(}}X5Kp6 z-Um@?BN0ReUY7-^e;>eY^zv19j?`Mj*gC}20|ve_VKmSCSPMWcRE^PRLu_Dh+m8K z(CseB=dImD>;MdkEp3h^7Ck}t=fbt_2r~D8&CF0W!_5Mk(-+{tZFuq&7p^U&wYWch zEe#pnP(lJiD2>>DcICfb%PjBG4h+Vp;wnq^rRpBc3usY>t#<{7d^u9r*mbHcRNhh7 ztl5b|Bj{O0c5v=~Q^jM5u%e`8C zT4ud|)i}WDV|v%SDv}YPYj1e>wXB1DDFIwxHGrfnE|7d+I)CmB-?Dcvk}p7-<@~rZ zR;KraDLZha)Pk2_WB6L$~>VZE&2)Mr@!a_XMA=Dy&)e`esZ2X%i8t>OD1|cS2_l>{{^2}uiO9t literal 0 HcmV?d00001 diff --git a/backend/media/library/modern/young_man.png b/backend/media/library/modern/young_man.png new file mode 100644 index 0000000000000000000000000000000000000000..0ca09e143ef429c46f5a177b3664b8ff321b9f96 GIT binary patch literal 1015 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxLp@AGtV45^s&_O4^zWe1Ul zMDao<6^Bo}77V}T_Ez&9;B{kCSr;I-&}(+pytx&}Hm2{(kG?v!_y3-alOH4kO(6#4 zec$k?eCHvHzVl1@BfL)Tk5A?M(8nC%?eLm&$wY~1LT5`^E;))=Ce2iz(A)Im^Y81+ z7j8Hozwe*!PTPZf)F(J4Ug3BU$H@8AN5Q;h<8g^Bm&tcjg-=W}=#z2jOb9x@uw?h+ z;>0a7N7rs#!sq-luf5gKUt=zt(9w&Jk3f{{ zH@W&`fvJpZU&J(F5BJ0vp;I>H8?Aa5KmDt9k|nZI$hPkPH;XrSF89y(zj)qvl1<|s zf#ZUUc9@Ax^btNGS}0@Lqq?x;iS2a-*<%xL%U@S{&$;BVMO;V9JO``Z$E_YoOq^Tf z-1C1|ayR}-|ND2^<3gngJ@?{5t8d?5%EH9h7~z-FYuBYTL36L3dDY$b9h^Wh2L}ZK z0S*=xrpEq@{5$`8dt|>?x_Zy<^bD}D&c?D`F&kxWu?w>>F*P#Qcn!UAtIvcf4%w4n8@6oN>&wU_P1UbHI+Igk5{eMwnPt2+ZOkg!6yqp+MN*dve!Dh`1-cwrj#pHQde%hy;k*Q*3+vd@0aY4^6Pp0 z!{*M#d1}p1uXb{7njC($+HT6_FDHagoLT+s*5u`1n)AH!^Rxc@^c$DwtNG||&C;)2 z=69v~k8k0g)TSI0&u`y6Obd0An^=m(-+tfM5T`7@>!hKtg7LF=IkJk=cD_{avuV^R zcz=7pX{YitsU?pmueb_~e8yJ=vpATE$p=(c)37k}Kf_0cxPQ}M^UnmC?dj_0vd$@? F2>?)4cisR1 literal 0 HcmV?d00001 diff --git a/backend/media/library/modern/young_woman.png b/backend/media/library/modern/young_woman.png new file mode 100644 index 0000000000000000000000000000000000000000..a188a78a50613f98f9d2edc111362b19ee2aff34 GIT binary patch literal 1370 zcmeAS@N?(olHy`uVBq!ia0vp^Cm0wQbvW37thQf&_AxN9Dtfv&hE&XXd)G1Vv6)0e zpub@?BkL{KybH`F_U`j*`7cOk7_zdR;Ea-3F~MPirnj2wzbDnJ#Xg(}I&*$WlG@I@ zK%}l;aif`oS&WRvUbLVT)~H;+FJR22%>ub$Y(} z%C-tUb7>Vkf5lPb?ED1=k7hHqfBGsmCI9-d>b1+}21JSI2_zo*DrYbyK5IV179WKP z3IZK69;fd=T=4(;_MZ;dl)af*f9-0~+#=;^ z!t~Ojuhrw6u*dU49hag$2fq$6_b;EE$U)uv23zFjtTw(7OX-`MV4EPryVzkgrIo-Wm} zwwuY>26?;(zCM2U|Nj{W-P%8`ktziW-)pV6{xe9=ny{8V_0`7e=*#Qv>dsbAv^-$9 zX19R)&ItCPq{p%S_jhI`IzQg>YnL165!(}e^Bzk){1vAv;343VBx!JJ_v>F)wY&Z~ zg5ttM;KYN|t6NjAnKe2zIVeEHI6fBEzr5cc`ikxEXR|&b^S`Qn`|nkKihGbPlRDe; zhvbr{{9Cq_U30Vgr?EcxdgGQ?7Zc8!c6iIr=VxkU0hz;9QF3_BYqworKK@jg;B>9o z^!o$d+M1@nCvshuvai)RW!wz6?1kn8kKYv?|9`b|FF9g2|7+Wx!0kCxZmh6iz1M!# zRUu#!%uxa-{=4lD)c-oU4@ERWRoekxk6a(b1cy=T7Sq_4Xl z8%adZ@RehIJ@2uF@d?jvA?N*;(Es$XII VN%E(N9jMG?@O1TaS?83{1OT2?S|$Jh literal 0 HcmV?d00001 diff --git a/frontend/src/components/AssetPanel.vue b/frontend/src/components/AssetPanel.vue index d5a7685..316e325 100644 --- a/frontend/src/components/AssetPanel.vue +++ b/frontend/src/components/AssetPanel.vue @@ -5,52 +5,114 @@ - -
-
JQK 人物图
-
-
- -
-
{{ a.asset_key }}
- + +
+ + +
+ + +
+
+
JQK 人物图
+
+
+ +
+
{{ a.asset_key }}
+ +
+
+
+
+ +
+
大小王
+
+
+ +
+
{{ a.asset_key === 'joker-big' ? '大王' : a.asset_key === 'joker-small' ? '小王' : a.asset_key }}
+ +
+
+
+
+ +
+
背面图案
+
+
+ +
+
{{ a.asset_key }}
+ +
+
+
+
+ +
+

还没有素材

+

点击右上「上传」按钮,添加 JQK 人物、大小王图或背面

+

JQK 只需上传上半身图,系统会自动生成中心对称的完整牌面

+

或切到「预设主题」标签页快速套用 4 套主题

+
+
+ + +
+
加载主题中…
+
{{ libraryError }}
+
+ +
+ + +
+ +
+ {{ selectedThemeObj.description }} +
+ +
+

该主题没有素材

+
+ +
+
+ {{ g.theme_name }} + {{ g.items.length }} 张 +
+
+
+ +
+
{{ lib.role_name }}
+
{{ applyingId === lib.id ? '应用中…' : '点击套用' }}
+
+
-
- - -
-
大小王
-
-
- -
-
{{ a.asset_key === 'big' ? '大王' : '小王' }}
- -
-
-
-
- - -
-
背面图案
-
-
- -
-
{{ a.asset_key }}
- -
-
-
-
- -
-

还没有素材

-

点击右上「上传」按钮,添加 JQK 人物、大小王图或背面

-

JQK 只需上传上半身图,系统会自动生成中心对称的完整牌面