Files
game-cards-poker-design/frontend/src/components/DesignPanel.vue
2026-06-04 22:15:48 +08:00

379 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="design-panel">
<!-- ===== 背面设计面板 ===== -->
<template v-if="isBack">
<section>
<h4>背面背景色</h4>
<div class="row">
<input type="color" :value="backDesign.background_color" @input="setBackBgColor($event.target.value)" />
<input type="text" :value="backDesign.background_color" @change="setBackBgColor($event.target.value)" />
</div>
</section>
<section>
<h4>背面边框</h4>
<div class="row">
<label class="mini-label">颜色</label>
<input type="color" :value="backDesign.border_color" @input="setBackDesign('border_color', $event.target.value)" />
<label class="mini-label">粗细</label>
<input type="number" min="0" max="40" :value="backDesign.border_width"
@change="setBackDesign('border_width', parseInt($event.target.value) || 0)" />
</div>
</section>
<section>
<h4>图案色调</h4>
<p class="hint">为背面图案叠加一层半透明色调</p>
<div class="row">
<input type="color" :value="backDesign.pattern_color || '#C0A050'"
@input="setBackDesign('pattern_color', $event.target.value)" />
<button v-if="backDesign.pattern_color" @click="setBackDesign('pattern_color', null)" class="mini ghost">清除色调</button>
</div>
</section>
<section>
<h4>背面图片</h4>
<div class="row">
<button @click="uploadBackImage" class="mini">上传背面图</button>
</div>
<input ref="backImageInput" type="file" accept="image/*" @change="onBackImageFile" hidden />
<div v-if="backDesign.image" class="row">
<img :src="backDesign.image" class="thumb" />
<button @click="setBackDesign('image', null)" class="mini ghost">清除</button>
</div>
</section>
<section v-if="backDesign.image">
<h4>图片位置微调</h4>
<p class="hint">输入数值微调素材图片的位置与缩放0 = 默认</p>
<div class="pip-row">
<label class="mini-label">dx</label>
<input type="number" step="0.005" min="-0.05" max="0.05"
:value="backImageOffsetVal('image_dx')" @change="setBackImageOffset('image_dx', $event.target.value)" />
<label class="mini-label">dy</label>
<input type="number" step="0.005" min="-0.05" max="0.05"
:value="backImageOffsetVal('image_dy')" @change="setBackImageOffset('image_dy', $event.target.value)" />
<label class="mini-label">缩放</label>
<input type="number" step="0.05" min="0.6" max="1.4"
:value="backImageOffsetVal('image_scale')" @change="setBackImageOffset('image_scale', $event.target.value)" />
</div>
<button @click="resetBackImageOffset" class="mini ghost">重置图片位置</button>
</section>
</template>
<!-- ===== 正面设计面板 ===== -->
<template v-else>
<!-- 背景色 -->
<section>
<h4>背景色</h4>
<p class="hint">整副牌默认用这个颜色个别牌可在下面单独覆盖</p>
<div class="row">
<input type="color" :value="design.background_color" @input="setBgColor($event.target.value)" />
<input type="text" :value="design.background_color" @change="setBgColor($event.target.value)" />
<button @click="uploadBg" class="mini">上传背景图</button>
</div>
<input ref="bgFileInput" type="file" accept="image/*" @change="onBgFile" hidden />
<div v-if="design.background_image" class="row">
<img :src="design.background_image" class="thumb" />
<button @click="clearBgImage" class="mini">清除</button>
</div>
</section>
<!-- 边框 -->
<section>
<h4>边框</h4>
<div class="row">
<label class="mini-label">颜色</label>
<input type="color" :value="design.border_color" @input="setBorder('border_color', $event.target.value)" />
<label class="mini-label">粗细</label>
<input type="number" min="0" max="40" :value="design.border_width"
@change="setBorder('border_width', parseInt($event.target.value) || 0)" />
</div>
</section>
<!-- 花色符号 -->
<section>
<h4>花色符号</h4>
<p class="hint">用系统字体显示一个 Unicode 符号想用图片可在右侧素材库上传后切换</p>
<div v-for="s in suits" :key="s" class="suit-row">
<div class="suit-label">{{ suitSymbol(s) }} {{ suitLabel(s) }}</div>
<input type="color" :value="design.suit_symbols?.[s]?.color || '#000000'"
@input="setSuit(s, 'color', $event.target.value)" />
<select :value="design.suit_symbols?.[s]?.type || 'text'"
@change="setSuit(s, 'type', $event.target.value)">
<option value="text">字体符号</option>
<option value="image" :disabled="!design.suit_symbols?.[s]?.asset_id">图片素材</option>
</select>
</div>
</section>
<!-- 尺寸 -->
<section>
<h4>大小比例</h4>
<label class="row">
<span>角标</span>
<input type="range" min="0.08" max="0.20" step="0.01"
:value="design.corner_size_ratio"
@input="setDesign('corner_size_ratio', parseFloat($event.target.value))" />
<span class="val">{{ Math.round(design.corner_size_ratio * 100) }}%</span>
</label>
<label class="row">
<span>中心花色</span>
<input type="range" min="0.08" max="0.24" step="0.01"
:value="design.pip_size_ratio"
@input="setDesign('pip_size_ratio', parseFloat($event.target.value))" />
<span class="val">{{ Math.round(design.pip_size_ratio * 100) }}%</span>
</label>
</section>
<!-- 单牌覆盖 -->
<section v-if="canHaveOverride">
<h4>本牌特殊设置</h4>
<p class="hint">只对当前选中的牌生效</p>
<div class="row">
<button @click="setOverrideBg" class="mini">设置独立背景色</button>
<button v-if="hasOverride" @click="clearOverride" class="mini ghost">清除</button>
</div>
<div v-if="override?.background_color" class="row">
<input type="color" :value="override.background_color"
@input="patchOverride('background_color', $event.target.value)" />
<input type="text" :value="override.background_color"
@change="patchOverride('background_color', $event.target.value)" />
</div>
</section>
<!-- 对称方式JQK / 大小王 -->
<section v-if="showSymmetryMode">
<h4>对称方式</h4>
<p class="hint">下半身素材的生成方式</p>
<div class="row">
<select :value="symmetryMode" @change="setSymmetryMode($event.target.value)">
<option value="flip">垂直翻转左右不变</option>
<option value="rotate">180° 旋转左右镜像</option>
</select>
</div>
</section>
<!-- 图片位置微调大小王 -->
<section v-if="showImageOffset">
<h4>图片位置微调</h4>
<p class="hint">输入数值微调素材图片的位置与缩放0 = 默认</p>
<div class="pip-row">
<label class="mini-label">dx</label>
<input type="number" step="0.005" min="-0.05" max="0.05"
:value="imageOffsetVal('image_dx')" @change="setImageOffset('image_dx', $event.target.value)" />
<label class="mini-label">dy</label>
<input type="number" step="0.005" min="-0.05" max="0.05"
:value="imageOffsetVal('image_dy')" @change="setImageOffset('image_dy', $event.target.value)" />
<label class="mini-label">缩放</label>
<input type="number" step="0.05" min="0.6" max="1.4"
:value="imageOffsetVal('image_scale')" @change="setImageOffset('image_scale', $event.target.value)" />
</div>
<button @click="resetImageOffset" class="mini ghost">重置图片位置</button>
</section>
<!-- 数字牌花色位置微调 -->
<section v-if="isNumberCard">
<h4>数字牌花色位置微调</h4>
<p class="hint">输入数值微调每个花色的位置占整张牌的比例0 = 默认</p>
<div class="rank-tabs">
<button v-for="r in [1,2,3,4,5,6,7,8,9,10]" :key="r"
:class="{ active: selectedRank === r }"
@click="selectedRank = r">{{ r === 1 ? 'A' : r }}</button>
</div>
<div v-for="(pos, i) in positions" :key="i" class="pip-row">
<div class="pip-label">#{{ i + 1 }}</div>
<label class="mini-label">dx</label>
<input type="number" step="0.005" min="-0.05" max="0.05"
:value="overrideFor(i).dx" @change="setOffset(i, 'dx', $event.target.value)" />
<label class="mini-label">dy</label>
<input type="number" step="0.005" min="-0.05" max="0.05"
:value="overrideFor(i).dy" @change="setOffset(i, 'dy', $event.target.value)" />
<label class="mini-label">缩放</label>
<input type="number" step="0.05" min="0.6" max="1.4"
:value="overrideFor(i).scale" @change="setOffset(i, 'scale', $event.target.value)" />
</div>
<button @click="resetLayout" class="mini ghost">重置本点数布局</button>
</section>
</template>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useProjectStore } from '@/stores/projectStore'
import { SUITS, SUIT_TEXT, LAYOUT_POSITIONS, isJoker } from '@/utils/cardLayout'
const store = useProjectStore()
const design = computed(() => store.effectiveDesign)
const backDesign = computed(() => store.effectiveBackDesign)
const isBack = computed(() => store.currentCard === 'back')
const override = computed(() => {
if (!store.project || !store.currentCard) return null
return (store.project.card_overrides || {})[store.currentCard] || null
})
const hasOverride = computed(() => !!override.value)
const suits = SUITS
const suitSymbol = (s) => SUIT_TEXT[s]
const suitLabel = (s) => ({ spade: '黑桃', heart: '红桃', club: '梅花', diamond: '方块' })[s]
// ===== 正面相关 =====
const bgFileInput = ref(null)
function uploadBg() { bgFileInput.value?.click() }
function onBgFile(e) {
const f = e.target.files[0]
if (!f) return
const reader = new FileReader()
reader.onload = (ev) => {
store.patchDesign('background_image', ev.target.result)
}
reader.readAsDataURL(f)
}
function clearBgImage() { store.patchDesign('background_image', null) }
function setBgColor(v) { store.patchDesign('background_color', v) }
function setBorder(path, v) { store.patchDesign(path, v) }
function setSuit(suit, path, v) { store.patchDesign(`suit_symbols.${suit}.${path}`, v) }
function setDesign(path, v) { store.patchDesign(path, v) }
const canHaveOverride = computed(() => true)
function setOverrideBg() {
if (!canHaveOverride.value) return
store.patchCardOverride(store.currentCard, 'background_color', design.value.background_color)
}
function patchOverride(path, v) {
store.patchCardOverride(store.currentCard, path, v)
}
function clearOverride() {
store.clearCardOverride(store.currentCard)
}
const isNumberCard = computed(() => {
if (!store.currentCard || !store.currentCard.includes('-')) return false
const r = store.currentCard.split('-')[1]
return /^[0-9]+$/.test(r) || r === 'A'
})
const showImageOffset = computed(() => {
return isJoker(store.currentCard)
})
const isFaceCard = computed(() => {
if (!store.currentCard || !store.currentCard.includes('-')) return false
const r = store.currentCard.split('-')[1]
return r === 'J' || r === 'Q' || r === 'K'
})
const showSymmetryMode = computed(() => {
return isFaceCard.value || isJoker(store.currentCard)
})
const symmetryMode = computed(() => design.value.symmetry_mode || 'flip')
function setSymmetryMode(v) {
store.patchCardOverride(store.currentCard, 'symmetry_mode', v)
}
function imageOffsetVal(key) {
if (!override.value) return key === 'image_scale' ? 1 : 0
const v = Number(override.value[key])
if (Number.isNaN(v)) return key === 'image_scale' ? 1 : 0
return v
}
function setImageOffset(key, val) {
store.patchCardOverride(store.currentCard, key, parseFloat(val))
}
function resetImageOffset() {
store.patchCardOverride(store.currentCard, 'image_dx', 0)
store.patchCardOverride(store.currentCard, 'image_dy', 0)
store.patchCardOverride(store.currentCard, 'image_scale', 1)
}
const selectedRank = ref(1)
watch(() => store.currentCard, () => {
if (isNumberCard.value) {
const r = store.currentCard.split('-')[1]
selectedRank.value = r === 'A' ? 1 : parseInt(r)
}
})
const positions = computed(() => LAYOUT_POSITIONS[selectedRank.value] || LAYOUT_POSITIONS[1])
function overrideFor(i) {
const list = (store.project?.number_layout || {})[String(selectedRank.value)] || []
const o = list[i] || {}
return {
dx: Number(o.dx) || 0,
dy: Number(o.dy) || 0,
scale: Number(o.scale) || 1,
}
}
function setOffset(i, key, val) {
store.patchNumberLayout(String(selectedRank.value), i, { [key]: parseFloat(val) })
}
function resetLayout() {
store.resetNumberLayout(String(selectedRank.value))
}
// ===== 背面相关 =====
const backImageInput = ref(null)
function uploadBackImage() { backImageInput.value?.click() }
function onBackImageFile(e) {
const f = e.target.files[0]
if (!f) return
const reader = new FileReader()
reader.onload = (ev) => {
store.patchBackDesign('image', ev.target.result)
}
reader.readAsDataURL(f)
}
function setBackBgColor(v) { store.patchBackDesign('background_color', v) }
function setBackDesign(path, v) { store.patchBackDesign(path, v) }
function backImageOffsetVal(key) {
const v = Number(backDesign.value[key])
if (Number.isNaN(v)) return key === 'image_scale' ? 1 : 0
return v
}
function setBackImageOffset(key, val) {
store.patchBackDesign(key, parseFloat(val))
}
function resetBackImageOffset() {
store.patchBackDesign('image_dx', 0)
store.patchBackDesign('image_dy', 0)
store.patchBackDesign('image_scale', 1)
}
</script>
<style scoped>
.design-panel section { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid #16213e; }
.design-panel h4 { margin: 0 0 6px 0; font-size: 13px; color: #ccc; }
.hint { font-size: 11px; color: #777; margin: 0 0 8px 0; line-height: 1.4; }
.row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.row span:first-child { font-size: 12px; color: #aaa; min-width: 56px; }
.row .val { font-size: 11px; color: #888; min-width: 32px; text-align: right; }
input[type="color"] { width: 28px; height: 24px; border: none; border-radius: 3px; cursor: pointer; background: transparent; }
input[type="text"] { flex: 1; background: #16213e; border: 1px solid #16213e; color: #eee; padding: 4px 6px; border-radius: 3px; font-size: 12px; }
input[type="number"] { width: 50px; background: #16213e; border: 1px solid #16213e; color: #eee; padding: 4px; border-radius: 3px; font-size: 12px; }
input[type="range"] { flex: 1; }
select { background: #16213e; border: 1px solid #16213e; color: #eee; padding: 4px; border-radius: 3px; font-size: 12px; }
.mini { background: #16213e; color: #aaa; padding: 4px 8px; font-size: 11px; border-radius: 3px; }
.mini.ghost { background: transparent; border: 1px solid #555; }
.mini-label { font-size: 11px; color: #888; min-width: 24px; }
.thumb { width: 60px; height: 30px; object-fit: cover; border-radius: 3px; }
.suit-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.suit-label { font-size: 12px; min-width: 70px; color: #ccc; }
.suit-row select { flex: 1; }
.rank-tabs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-bottom: 10px; }
.rank-tabs button { background: #16213e; color: #aaa; padding: 4px; font-size: 11px; border-radius: 3px; }
.rank-tabs button.active { background: #e94560; color: white; }
.pip-row { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; padding: 4px; background: #16213e; border-radius: 4px; }
.pip-label { font-size: 11px; min-width: 24px; color: #888; }
</style>