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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user