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 0000000..73f6039 Binary files /dev/null and b/backend/media/library/astronomy/blackhole.png differ diff --git a/backend/media/library/astronomy/moon.png b/backend/media/library/astronomy/moon.png new file mode 100644 index 0000000..109081e Binary files /dev/null and b/backend/media/library/astronomy/moon.png differ diff --git a/backend/media/library/astronomy/star.png b/backend/media/library/astronomy/star.png new file mode 100644 index 0000000..20a6d11 Binary files /dev/null and b/backend/media/library/astronomy/star.png differ diff --git a/backend/media/library/astronomy/sun.png b/backend/media/library/astronomy/sun.png new file mode 100644 index 0000000..57f220c Binary files /dev/null and b/backend/media/library/astronomy/sun.png differ diff --git a/backend/media/library/classical/jester.png b/backend/media/library/classical/jester.png new file mode 100644 index 0000000..efe3808 Binary files /dev/null and b/backend/media/library/classical/jester.png differ diff --git a/backend/media/library/classical/king.png b/backend/media/library/classical/king.png new file mode 100644 index 0000000..e2e0f4f Binary files /dev/null and b/backend/media/library/classical/king.png differ diff --git a/backend/media/library/classical/prince.png b/backend/media/library/classical/prince.png new file mode 100644 index 0000000..4f4bda3 Binary files /dev/null and b/backend/media/library/classical/prince.png differ diff --git a/backend/media/library/classical/queen.png b/backend/media/library/classical/queen.png new file mode 100644 index 0000000..483a30b Binary files /dev/null and b/backend/media/library/classical/queen.png differ diff --git a/backend/media/library/minimal/cross.png b/backend/media/library/minimal/cross.png new file mode 100644 index 0000000..33e54fb Binary files /dev/null and b/backend/media/library/minimal/cross.png differ diff --git a/backend/media/library/minimal/dot.png b/backend/media/library/minimal/dot.png new file mode 100644 index 0000000..9cb8c6f Binary files /dev/null and b/backend/media/library/minimal/dot.png differ diff --git a/backend/media/library/minimal/mars.png b/backend/media/library/minimal/mars.png new file mode 100644 index 0000000..0da8db8 Binary files /dev/null and b/backend/media/library/minimal/mars.png differ diff --git a/backend/media/library/minimal/venus.png b/backend/media/library/minimal/venus.png new file mode 100644 index 0000000..6a7b91f Binary files /dev/null and b/backend/media/library/minimal/venus.png differ diff --git a/backend/media/library/modern/child.png b/backend/media/library/modern/child.png new file mode 100644 index 0000000..34adb89 Binary files /dev/null and b/backend/media/library/modern/child.png differ diff --git a/backend/media/library/modern/clownfish.png b/backend/media/library/modern/clownfish.png new file mode 100644 index 0000000..2d16968 Binary files /dev/null and b/backend/media/library/modern/clownfish.png differ diff --git a/backend/media/library/modern/young_man.png b/backend/media/library/modern/young_man.png new file mode 100644 index 0000000..0ca09e1 Binary files /dev/null and b/backend/media/library/modern/young_man.png differ diff --git a/backend/media/library/modern/young_woman.png b/backend/media/library/modern/young_woman.png new file mode 100644 index 0000000..a188a78 Binary files /dev/null and b/backend/media/library/modern/young_woman.png differ 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 只需上传上半身图,系统会自动生成中心对称的完整牌面