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,深色描边 + 半透明浅色填充,在任意牌面背景上叠加都清晰
This commit is contained in:
Developer
2026-06-02 14:39:52 +08:00
parent 0a22a0c0d2
commit 5ca000b8ab
24 changed files with 815 additions and 47 deletions

View File

@@ -5,52 +5,114 @@
<button class="primary" @click="showUpload = true">+ 上传</button>
</div>
<!-- JQK 人物图 -->
<section v-if="faceCardAssets.length">
<h5>JQK 人物图</h5>
<div class="grid">
<div v-for="a in faceCardAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
<!-- 标签页 -->
<div 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">
<h5>JQK 人物图</h5>
<div class="grid">
<div v-for="a in faceCardAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<section v-if="jokerAssets.length">
<h5>大小王</h5>
<div class="grid">
<div v-for="a in jokerAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key === 'joker-big' ? '大王' : a.asset_key === 'joker-small' ? '小王' : a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<section v-if="backAssets.length">
<h5>背面图案</h5>
<div class="grid">
<div v-for="a in backAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<div v-if="!hasAny" class="empty">
<p>还没有素材</p>
<p class="hint">点击右上上传按钮添加 JQK 人物大小王图或背面</p>
<p class="hint">JQK 只需上传上半身图系统会自动生成中心对称的完整牌面</p>
<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>
</section>
<!-- 大小王 -->
<section v-if="jokerAssets.length">
<h5>大小王</h5>
<div class="grid">
<div v-for="a in jokerAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key === 'big' ? '大王' : '小王' }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<!-- 背面 -->
<section v-if="backAssets.length">
<h5>背面图案</h5>
<div class="grid">
<div v-for="a in backAssets" :key="a.id" class="asset-tile">
<img :src="a.file_url" />
<div class="meta">
<div class="key">{{ a.asset_key }}</div>
<button class="mini ghost" @click="del(a)">删除</button>
</div>
</div>
</div>
</section>
<div v-if="!hasAny" class="empty">
<p>还没有素材</p>
<p class="hint">点击右上上传按钮添加 JQK 人物大小王图或背面</p>
<p class="hint">JQK 只需上传上半身图系统会自动生成中心对称的完整牌面</p>
</div>
<AssetUploadDialog
@@ -62,7 +124,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import axios from 'axios'
import { useProjectStore } from '@/stores/projectStore'
@@ -70,7 +132,9 @@ import AssetUploadDialog from '@/components/AssetUploadDialog.vue'
const store = useProjectStore()
const showUpload = ref(false)
const activeTab = ref('mine')
// ----- 我的素材 -----
const assets = computed(() => store.project?.assets || [])
const faceCardAssets = computed(() => assets.value.filter(a => a.asset_type === 'face_card'))
const jokerAssets = computed(() => assets.value.filter(a => a.asset_type === 'joker'))
@@ -91,6 +155,75 @@ async function del(a) {
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>
<style scoped>
@@ -98,16 +231,37 @@ async function del(a) {
.asset-panel h5 { margin: 0 0 8px 0; font-size: 12px; color: #aaa; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
button.primary { background: #e94560; color: #fff; border: none; border-radius: 3px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
.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; }
.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 .meta { display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; }
.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.ghost { background: transparent; color: #888; border: 1px solid #444; }
.mini.ghost:hover { color: #e94560; border-color: #e94560; }
.empty { text-align: center; padding: 20px 8px; color: #777; }
.empty p { margin: 4px 0; font-size: 12px; }
.empty .hint { font-size: 11px; color: #555; line-height: 1.4; }
.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>