重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器
This commit is contained in:
113
frontend/src/components/AssetPanel.vue
Normal file
113
frontend/src/components/AssetPanel.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="asset-panel">
|
||||
<div class="header">
|
||||
<h4>素材库</h4>
|
||||
<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>
|
||||
</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
|
||||
v-model="showUpload"
|
||||
:project-id="store.project?.id"
|
||||
@uploaded="onUploaded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import { useProjectStore } from '@/stores/projectStore'
|
||||
import AssetUploadDialog from '@/components/AssetUploadDialog.vue'
|
||||
|
||||
const store = useProjectStore()
|
||||
const showUpload = ref(false)
|
||||
|
||||
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'))
|
||||
const backAssets = computed(() => assets.value.filter(a => a.asset_type === 'back'))
|
||||
const hasAny = computed(() => assets.value.length > 0)
|
||||
|
||||
async function onUploaded() {
|
||||
await store.refreshAssets()
|
||||
}
|
||||
|
||||
async function del(a) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${a.asset_key}」素材?`, '删除', { type: 'warning' })
|
||||
await axios.delete(`/api/projects/${store.project.id}/assets/${a.id}/`)
|
||||
ElMessage.success('已删除')
|
||||
await store.refreshAssets()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-panel h4 { margin: 0; font-size: 14px; color: #ccc; }
|
||||
.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; }
|
||||
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 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; }
|
||||
.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; }
|
||||
</style>
|
||||
@@ -1,46 +1,67 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="上传素材"
|
||||
width="500px"
|
||||
:title="title"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="素材类型">
|
||||
<el-select v-model="form.assetType" placeholder="选择素材类型">
|
||||
<el-option label="花色图案" value="suit_symbol" />
|
||||
<el-option label="JQK人像" value="face_card" />
|
||||
<el-option label="大小王" value="joker" />
|
||||
<el-form-item label="素材类型" required>
|
||||
<el-select v-model="form.assetType" placeholder="选择类型" style="width: 100%;">
|
||||
<el-option label="JQK 人物图" value="face_card" />
|
||||
<el-option label="大王/小王图" value="joker" />
|
||||
<el-option label="背面图案" value="back" />
|
||||
<el-option label="花色符号图" value="suit_symbol" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="素材标识">
|
||||
<el-input
|
||||
<el-form-item label="素材标识" required>
|
||||
<el-select
|
||||
v-if="form.assetType === 'face_card' || form.assetType === 'joker' || form.assetType === 'suit_symbol'"
|
||||
v-model="form.assetKey"
|
||||
placeholder="如:spade, heart-J, big_joker"
|
||||
:placeholder="keyPlaceholder"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<template v-if="form.assetType === 'face_card'">
|
||||
<el-option-group v-for="s in suitOptions" :key="s" :label="suitLabel(s)">
|
||||
<el-option v-for="r in faceRanks" :key="`${s}-${r}`" :label="`${suitSymbol(s)} ${r}`" :value="`${s}-${r}`" />
|
||||
</el-option-group>
|
||||
</template>
|
||||
<template v-else-if="form.assetType === 'joker'">
|
||||
<el-option label="大王 (BIG)" value="big" />
|
||||
<el-option label="小王 (SMALL)" value="small" />
|
||||
</template>
|
||||
<template v-else-if="form.assetType === 'suit_symbol'">
|
||||
<el-option v-for="s in suitOptions" :key="s" :label="`${suitSymbol(s)} ${suitLabel(s)}`" :value="s" />
|
||||
</template>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="form.assetKey"
|
||||
:placeholder="keyPlaceholder"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择文件">
|
||||
<el-upload
|
||||
ref="upload"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:limit="1"
|
||||
:on-exceed="handleExceed"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
拖拽文件到此处或<em>点击上传</em>
|
||||
<el-form-item label="选择文件" required>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
@change="handleFileChange"
|
||||
style="display: none;"
|
||||
/>
|
||||
<div class="upload-area" @click="fileInput?.click()" @drop.prevent="handleDrop" @dragover.prevent>
|
||||
<div v-if="!form.preview" class="placeholder">
|
||||
<div class="icon">+</div>
|
||||
<div>点击或拖拽图片到此处</div>
|
||||
<div class="hint">支持 PNG / JPG / SVG / WebP,建议透明背景</div>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持 PNG, JPG, SVG 格式
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<div v-else class="preview">
|
||||
<img :src="form.preview" alt="preview" />
|
||||
<div class="filename">{{ form.file?.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -61,44 +82,59 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { uploadAsset } from '@/api/asset'
|
||||
import axios from 'axios'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
projectId: String
|
||||
projectId: String,
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'uploaded'])
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success'])
|
||||
const dialogVisible = ref(props.modelValue)
|
||||
const upload = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
const form = ref({ assetType: 'face_card', assetKey: '', file: null, preview: null })
|
||||
|
||||
const form = ref({
|
||||
assetType: 'suit_symbol',
|
||||
assetKey: '',
|
||||
file: null
|
||||
const suitOptions = ['spade', 'heart', 'club', 'diamond']
|
||||
const faceRanks = ['J', 'Q', 'K']
|
||||
const suitSymbol = (s) => ({ spade: '♠', heart: '♥', club: '♣', diamond: '♦' })[s]
|
||||
const suitLabel = (s) => ({ spade: '黑桃', heart: '红桃', club: '梅花', diamond: '方块' })[s]
|
||||
|
||||
const title = computed(() => '上传素材')
|
||||
const keyPlaceholder = computed(() => {
|
||||
switch (form.value.assetType) {
|
||||
case 'face_card': return '选择 JQK 牌'
|
||||
case 'joker': return '选择 大王/小王'
|
||||
case 'suit_symbol': return '选择花色'
|
||||
default: return '例如 back'
|
||||
}
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return form.value.assetKey && selectedFile.value
|
||||
return form.value.assetType && form.value.assetKey && form.value.file
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
dialogVisible.value = val
|
||||
})
|
||||
watch(() => props.modelValue, (val) => { dialogVisible.value = val })
|
||||
watch(dialogVisible, (val) => { emit('update:modelValue', val) })
|
||||
watch(() => form.value.assetType, () => { form.value.assetKey = '' })
|
||||
|
||||
watch(dialogVisible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
function handleFileChange(file) {
|
||||
selectedFile.value = file.raw
|
||||
function handleFileChange(e) {
|
||||
const f = e.target.files[0]
|
||||
if (f) acceptFile(f)
|
||||
}
|
||||
|
||||
function handleExceed() {
|
||||
ElMessage.warning('只能上传一个文件')
|
||||
function handleDrop(e) {
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f) acceptFile(f)
|
||||
}
|
||||
function acceptFile(f) {
|
||||
if (!f.type.startsWith('image/')) {
|
||||
ElMessage.warning('请上传图片文件')
|
||||
return
|
||||
}
|
||||
form.value.file = f
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => { form.value.preview = ev.target.result }
|
||||
reader.readAsDataURL(f)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -106,22 +142,20 @@ async function handleSubmit() {
|
||||
ElMessage.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('file', form.value.file)
|
||||
fd.append('asset_type', form.value.assetType)
|
||||
fd.append('asset_key', form.value.assetKey)
|
||||
uploading.value = true
|
||||
try {
|
||||
uploading.value = true
|
||||
await uploadAsset(
|
||||
props.projectId,
|
||||
selectedFile.value,
|
||||
form.value.assetType,
|
||||
form.value.assetKey
|
||||
)
|
||||
|
||||
const r = await axios.post(`/api/projects/${props.projectId}/assets/`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
ElMessage.success('上传成功')
|
||||
emit('upload-success')
|
||||
emit('uploaded', r.data)
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败')
|
||||
console.error(error)
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败: ' + (e.response?.data?.error || e.message))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
@@ -130,25 +164,28 @@ async function handleSubmit() {
|
||||
function handleClose() {
|
||||
dialogVisible.value = false
|
||||
emit('update:modelValue', false)
|
||||
// Reset form
|
||||
form.value = {
|
||||
assetType: 'suit_symbol',
|
||||
assetKey: '',
|
||||
file: null
|
||||
}
|
||||
selectedFile.value = null
|
||||
form.value = { assetType: 'face_card', assetKey: '', file: null, preview: null }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-upload__text {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
border: 2px dashed #555;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fafafa;
|
||||
}
|
||||
.upload-area:hover { border-color: #e94560; background: #fff5f7; }
|
||||
.placeholder { text-align: center; color: #888; }
|
||||
.placeholder .icon { font-size: 36px; color: #aaa; }
|
||||
.placeholder .hint { font-size: 12px; margin-top: 6px; }
|
||||
.preview { text-align: center; }
|
||||
.preview img { max-width: 200px; max-height: 120px; object-fit: contain; }
|
||||
.preview .filename { font-size: 12px; color: #666; margin-top: 6px; }
|
||||
</style>
|
||||
|
||||
218
frontend/src/components/DesignPanel.vue
Normal file
218
frontend/src/components/DesignPanel.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="design-panel">
|
||||
<!-- 背景色 -->
|
||||
<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>
|
||||
|
||||
<!-- 数字牌花色位置微调 -->
|
||||
<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="range" min="-0.05" max="0.05" step="0.005"
|
||||
:value="overrideFor(i).dx" @input="setOffset(i, 'dx', $event.target.value)" />
|
||||
<label class="mini-label">dy</label>
|
||||
<input type="range" min="-0.05" max="0.05" step="0.005"
|
||||
:value="overrideFor(i).dy" @input="setOffset(i, 'dy', $event.target.value)" />
|
||||
<label class="mini-label">缩放</label>
|
||||
<input type="range" min="0.6" max="1.4" step="0.05"
|
||||
:value="overrideFor(i).scale" @input="setOffset(i, 'scale', $event.target.value)" />
|
||||
</div>
|
||||
<button @click="resetLayout" class="mini ghost">重置本点数布局</button>
|
||||
</section>
|
||||
</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 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) => {
|
||||
// 这里只把 dataURL 临时存到 design;正式应上传到后端
|
||||
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(() => {
|
||||
return !isJoker(store.currentCard) && store.currentCard !== 'back'
|
||||
})
|
||||
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 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))
|
||||
}
|
||||
</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>
|
||||
197
frontend/src/stores/projectStore.js
Normal file
197
frontend/src/stores/projectStore.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { DEFAULT_DESIGN, listAllCards } from '@/utils/cardLayout.js'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
/**
|
||||
* 项目状态管理:当前编辑的项目 + 当前选中的牌
|
||||
*/
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
// 全部项目列表
|
||||
const projects = ref([])
|
||||
// 当前打开的项目
|
||||
const project = ref(null)
|
||||
// 当前选中的牌 key
|
||||
const currentCard = ref('spade-A')
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 全部 54 张牌 + 背面
|
||||
const allCards = computed(() => listAllCards())
|
||||
|
||||
// 当前牌的有效 design(合并项目级和单牌覆盖)
|
||||
const effectiveDesign = computed(() => {
|
||||
if (!project.value) return DEFAULT_DESIGN
|
||||
const base = JSON.parse(JSON.stringify(project.value.design || DEFAULT_DESIGN))
|
||||
const ovr = (project.value.card_overrides || {})[currentCard.value] || {}
|
||||
return { ...base, ...ovr }
|
||||
})
|
||||
|
||||
async function fetchProjects() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await axios.get(`${API}/projects/`)
|
||||
projects.value = r.data || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name, templateId = 'classic') {
|
||||
const r = await axios.post(`${API}/projects/`, {
|
||||
name,
|
||||
template_id: templateId,
|
||||
design: DEFAULT_DESIGN,
|
||||
card_overrides: {},
|
||||
number_layout: {},
|
||||
})
|
||||
projects.value.unshift(r.data)
|
||||
return r.data
|
||||
}
|
||||
|
||||
async function deleteProject(id) {
|
||||
await axios.delete(`${API}/projects/${id}/`)
|
||||
projects.value = projects.value.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
async function loadProject(id) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await axios.get(`${API}/projects/${id}/`)
|
||||
// 兜底:如果老数据没有 design 字段,补上
|
||||
if (!r.data.design) r.data.design = JSON.parse(JSON.stringify(DEFAULT_DESIGN))
|
||||
if (!r.data.card_overrides) r.data.card_overrides = {}
|
||||
if (!r.data.number_layout) r.data.number_layout = {}
|
||||
if (!r.data.assets) r.data.assets = []
|
||||
project.value = r.data
|
||||
currentCard.value = 'spade-A'
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveName(name) {
|
||||
if (!project.value) return
|
||||
project.value.name = name
|
||||
await axios.put(`${API}/projects/${project.value.id}/`, { name })
|
||||
}
|
||||
|
||||
/**
|
||||
* 整体保存设计(design / card_overrides / number_layout / face_orientations)
|
||||
* 自动防抖
|
||||
*/
|
||||
let saveTimer = null
|
||||
function scheduleSaveDesign() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(saveDesign, 500)
|
||||
}
|
||||
async function saveDesign() {
|
||||
if (!project.value) return
|
||||
try {
|
||||
await axios.post(`${API}/projects/${project.value.id}/design/`, {
|
||||
design: project.value.design,
|
||||
card_overrides: project.value.card_overrides,
|
||||
number_layout: project.value.number_layout,
|
||||
face_orientations: project.value.face_orientations || {},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('save design failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 design 中的某个字段,自动保存
|
||||
*/
|
||||
function patchDesign(path, value) {
|
||||
if (!project.value) return
|
||||
setPath(project.value.design, path, value)
|
||||
scheduleSaveDesign()
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改某张牌对项目级设计的覆盖
|
||||
*/
|
||||
function patchCardOverride(cardKey, path, value) {
|
||||
if (!project.value) return
|
||||
if (!project.value.card_overrides[cardKey]) {
|
||||
project.value.card_overrides[cardKey] = {}
|
||||
}
|
||||
setPath(project.value.card_overrides[cardKey], path, value)
|
||||
scheduleSaveDesign()
|
||||
}
|
||||
|
||||
function clearCardOverride(cardKey) {
|
||||
if (!project.value) return
|
||||
delete project.value.card_overrides[cardKey]
|
||||
scheduleSaveDesign()
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改数字牌花色位置(按 rank)
|
||||
* updates: { [index]: { dx, dy, scale } }
|
||||
*/
|
||||
function patchNumberLayout(rank, index, patch) {
|
||||
if (!project.value) return
|
||||
if (!project.value.number_layout[rank]) {
|
||||
project.value.number_layout[rank] = []
|
||||
}
|
||||
project.value.number_layout[rank][index] = {
|
||||
...(project.value.number_layout[rank][index] || {}),
|
||||
...patch,
|
||||
}
|
||||
scheduleSaveDesign()
|
||||
}
|
||||
|
||||
function resetNumberLayout(rank) {
|
||||
if (!project.value) return
|
||||
delete project.value.number_layout[rank]
|
||||
scheduleSaveDesign()
|
||||
}
|
||||
|
||||
function refreshAssets() {
|
||||
return loadProject(project.value.id)
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
project,
|
||||
currentCard,
|
||||
loading,
|
||||
error,
|
||||
allCards,
|
||||
effectiveDesign,
|
||||
fetchProjects,
|
||||
createProject,
|
||||
deleteProject,
|
||||
loadProject,
|
||||
saveName,
|
||||
saveDesign,
|
||||
scheduleSaveDesign,
|
||||
patchDesign,
|
||||
patchCardOverride,
|
||||
clearCardOverride,
|
||||
patchNumberLayout,
|
||||
resetNumberLayout,
|
||||
refreshAssets,
|
||||
}
|
||||
})
|
||||
|
||||
function setPath(obj, path, value) {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] === undefined) cur[parts[i]] = {}
|
||||
cur = cur[parts[i]]
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value
|
||||
}
|
||||
@@ -1,128 +1,138 @@
|
||||
const LAYOUT_POSITIONS = {
|
||||
1: [
|
||||
{ x: 0.5, y: 0.5 }
|
||||
],
|
||||
2: [
|
||||
{ x: 0.5, y: 0.25 },
|
||||
{ x: 0.5, y: 0.75 }
|
||||
],
|
||||
3: [
|
||||
{ x: 0.5, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.5, y: 0.8 }
|
||||
],
|
||||
4: [
|
||||
{ x: 0.3, y: 0.25 },
|
||||
{ x: 0.7, y: 0.25 },
|
||||
{ x: 0.3, y: 0.75 },
|
||||
{ x: 0.7, y: 0.75 }
|
||||
],
|
||||
5: [
|
||||
{ x: 0.3, y: 0.2 },
|
||||
{ x: 0.7, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.3, y: 0.8 },
|
||||
{ x: 0.7, y: 0.8 }
|
||||
],
|
||||
6: [
|
||||
{ x: 0.3, y: 0.2 },
|
||||
{ x: 0.7, y: 0.2 },
|
||||
{ x: 0.3, y: 0.5 },
|
||||
{ x: 0.7, y: 0.5 },
|
||||
{ x: 0.3, y: 0.8 },
|
||||
{ x: 0.7, y: 0.8 }
|
||||
],
|
||||
7: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.5, y: 0.35 },
|
||||
{ x: 0.3, y: 0.55 },
|
||||
{ x: 0.7, y: 0.55 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
],
|
||||
8: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.5, y: 0.35 },
|
||||
{ x: 0.3, y: 0.55 },
|
||||
{ x: 0.7, y: 0.55 },
|
||||
{ x: 0.5, y: 0.65 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
],
|
||||
9: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.5, y: 0.35 },
|
||||
{ x: 0.2, y: 0.5 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.8, y: 0.5 },
|
||||
{ x: 0.5, y: 0.65 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
],
|
||||
10: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.3, y: 0.35 },
|
||||
{ x: 0.7, y: 0.35 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.3, y: 0.65 },
|
||||
{ x: 0.7, y: 0.65 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
]
|
||||
/**
|
||||
* 扑克牌布局数据 & 通用工具函数
|
||||
*
|
||||
* 这里和后端 apps/exports/utils.py 保持一致。
|
||||
* 实际渲染在前端用 canvas(drawCard),后端用 PIL(generate_card_png)。
|
||||
*/
|
||||
|
||||
// 数字牌 1-10 的花色位置(相对坐标 0~1)
|
||||
export const LAYOUT_POSITIONS = {
|
||||
1: [{ x: 0.50, y: 0.50 }],
|
||||
2: [{ x: 0.50, y: 0.25 }, { x: 0.50, y: 0.75 }],
|
||||
3: [{ x: 0.50, y: 0.20 }, { x: 0.50, y: 0.50 }, { x: 0.50, y: 0.80 }],
|
||||
4: [{ x: 0.30, y: 0.25 }, { x: 0.70, y: 0.25 }, { x: 0.30, y: 0.75 }, { x: 0.70, y: 0.75 }],
|
||||
5: [{ x: 0.30, y: 0.20 }, { x: 0.70, y: 0.20 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.80 }, { x: 0.70, y: 0.80 }],
|
||||
6: [{ x: 0.30, y: 0.20 }, { x: 0.70, y: 0.20 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.30, y: 0.80 }, { x: 0.70, y: 0.80 }],
|
||||
7: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.35 }, { x: 0.30, y: 0.55 }, { x: 0.70, y: 0.55 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
8: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.32 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.50, y: 0.68 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
9: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.30 }, { x: 0.22, y: 0.50 }, { x: 0.50, y: 0.50 }, { x: 0.78, y: 0.50 }, { x: 0.50, y: 0.70 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
10: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.30, y: 0.35 }, { x: 0.70, y: 0.35 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.65 }, { x: 0.70, y: 0.65 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
}
|
||||
|
||||
export function calculateSuitPositions(rank, cardWidth, cardHeight, symbolSize = 60) {
|
||||
const positions = LAYOUT_POSITIONS[rank] || LAYOUT_POSITIONS[1]
|
||||
|
||||
return positions.map(pos => ({
|
||||
x: pos.x * cardWidth - symbolSize / 2,
|
||||
y: pos.y * cardHeight - symbolSize / 2,
|
||||
width: symbolSize,
|
||||
height: symbolSize
|
||||
}))
|
||||
export const SUIT_TEXT = {
|
||||
spade: '♠',
|
||||
heart: '♥',
|
||||
club: '♣',
|
||||
diamond: '♦',
|
||||
}
|
||||
|
||||
export function getCornerPositions(cardWidth, cardHeight) {
|
||||
return {
|
||||
topLeft: { x: 50, y: 50 },
|
||||
topRight: { x: cardWidth - 100, y: 50 },
|
||||
bottomLeft: { x: 50, y: cardHeight - 100 },
|
||||
bottomRight: { x: cardWidth - 100, y: cardHeight - 100 }
|
||||
}
|
||||
export const SUIT_LABELS = {
|
||||
spade: '♠ 黑桃',
|
||||
heart: '♥ 红桃',
|
||||
club: '♣ 梅花',
|
||||
diamond: '♦ 方块',
|
||||
}
|
||||
|
||||
export function getSuitSymbol(suit) {
|
||||
const symbols = {
|
||||
spade: '♠',
|
||||
heart: '♥',
|
||||
club: '♣',
|
||||
diamond: '♦'
|
||||
}
|
||||
return symbols[suit] || '♠'
|
||||
export const SUIT_COLORS = {
|
||||
spade: '#000000',
|
||||
heart: '#E53935',
|
||||
club: '#000000',
|
||||
diamond: '#E53935',
|
||||
}
|
||||
|
||||
export function getSuitColor(suit, templateColors) {
|
||||
if (templateColors && templateColors[suit]) {
|
||||
return templateColors[suit]
|
||||
}
|
||||
export const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
||||
|
||||
const colors = {
|
||||
spade: '#000000',
|
||||
heart: '#FF0000',
|
||||
club: '#000000',
|
||||
diamond: '#FF0000'
|
||||
}
|
||||
return colors[suit] || '#000000'
|
||||
export const SUITS = ['spade', 'heart', 'club', 'diamond']
|
||||
|
||||
export const JOKERS = [
|
||||
{ key: 'joker-big', label: '大王', defaultColor: '#1B5E20' },
|
||||
{ key: 'joker-small', label: '小王', defaultColor: '#B71C1C' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 合并项目级 design 与单牌覆盖
|
||||
*/
|
||||
export function getEffectiveDesign(project, cardKey) {
|
||||
const base = JSON.parse(JSON.stringify(project?.design || {}))
|
||||
const overrides = (project?.card_overrides || {})[cardKey] || {}
|
||||
return { ...base, ...overrides }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算数字牌 (1-10) 实际的花色位置(绝对像素)
|
||||
* - 默认按 LAYOUT_POSITIONS
|
||||
* - 项目可保存 number_layout 做微调
|
||||
*/
|
||||
export function computeNumberPipPositions(rank, cardW, cardH, pipSize, numberLayout) {
|
||||
const rankInt = parseInt(rank, 10) || 1
|
||||
const defaults = LAYOUT_POSITIONS[rankInt] || LAYOUT_POSITIONS[1]
|
||||
const userOverrides = (numberLayout || {})[String(rankInt)] || []
|
||||
return defaults.map((p, i) => {
|
||||
const o = userOverrides[i] || {}
|
||||
const dx = Number(o.dx) || 0
|
||||
const dy = Number(o.dy) || 0
|
||||
const scale = Number(o.scale) || 1
|
||||
return {
|
||||
x: (p.x + dx) * cardW,
|
||||
y: (p.y + dy) * cardH,
|
||||
size: Math.max(20, pipSize * scale),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个花色 key 是否为红色系
|
||||
*/
|
||||
export function isRedSuit(suit) {
|
||||
return suit === 'heart' || suit === 'diamond'
|
||||
}
|
||||
|
||||
export function isBlackSuit(suit) {
|
||||
return suit === 'spade' || suit === 'club'
|
||||
}
|
||||
}
|
||||
|
||||
export function isFace(rank) {
|
||||
return rank === 'J' || rank === 'Q' || rank === 'K'
|
||||
}
|
||||
|
||||
export function isJoker(cardKey) {
|
||||
return typeof cardKey === 'string' && cardKey.startsWith('joker-')
|
||||
}
|
||||
|
||||
export function isNumber(rank) {
|
||||
return RANKS.indexOf(rank) >= 0 && !isFace(rank)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出全部 54 张牌 + 背面
|
||||
*/
|
||||
export function listAllCards() {
|
||||
const out = []
|
||||
for (const s of SUITS) {
|
||||
for (const r of RANKS) {
|
||||
out.push({ key: `${s}-${r}`, suit: s, rank: r, type: isFace(r) ? 'face' : 'number' })
|
||||
}
|
||||
}
|
||||
out.push({ key: 'joker-big', suit: null, rank: null, type: 'joker' })
|
||||
out.push({ key: 'joker-small', suit: null, rank: null, type: 'joker' })
|
||||
out.push({ key: 'back', suit: null, rank: null, type: 'back' })
|
||||
return out
|
||||
}
|
||||
|
||||
export const DEFAULT_DESIGN = {
|
||||
background_color: '#FFFFFF',
|
||||
background_image: null,
|
||||
border_color: '#333333',
|
||||
border_width: 2,
|
||||
suit_symbols: {
|
||||
spade: { type: 'text', value: '♠', asset_id: null, color: '#000000' },
|
||||
heart: { type: 'text', value: '♥', asset_id: null, color: '#E53935' },
|
||||
club: { type: 'text', value: '♣', asset_id: null, color: '#000000' },
|
||||
diamond: { type: 'text', value: '♦', asset_id: null, color: '#E53935' },
|
||||
},
|
||||
corner_size_ratio: 0.13,
|
||||
pip_size_ratio: 0.16,
|
||||
font_family: 'Times New Roman',
|
||||
font_color: '#000000',
|
||||
corner_offset: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
402
frontend/src/utils/cardRenderer.js
Normal file
402
frontend/src/utils/cardRenderer.js
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 扑克牌 Canvas 渲染器
|
||||
*
|
||||
* 用原生 Canvas2D API 在前端渲染牌面,与后端 PIL 渲染保持一致。
|
||||
* 用户在编辑时实时看到的就是这个画面。
|
||||
*/
|
||||
import {
|
||||
SUIT_TEXT,
|
||||
SUIT_COLORS,
|
||||
isRedSuit,
|
||||
isFace,
|
||||
isJoker,
|
||||
computeNumberPipPositions,
|
||||
getEffectiveDesign,
|
||||
} from './cardLayout.js'
|
||||
|
||||
const CARD_W = 750
|
||||
const CARD_H = 1050
|
||||
|
||||
// 图片缓存(url -> HTMLImageElement)
|
||||
const imageCache = new Map()
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return resolve(null)
|
||||
if (imageCache.has(url)) {
|
||||
const cached = imageCache.get(url)
|
||||
if (cached.complete && cached.naturalWidth) return resolve(cached)
|
||||
// 还在加载中
|
||||
cached.addEventListener('load', () => resolve(cached), { once: true })
|
||||
cached.addEventListener('error', () => resolve(null), { once: true })
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
imageCache.set(url, img)
|
||||
img.addEventListener('load', () => resolve(img), { once: true })
|
||||
img.addEventListener('error', () => resolve(null), { once: true })
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
async function preloadAll(project) {
|
||||
const urls = new Set()
|
||||
if (project.design?.background_image) urls.add(project.design.background_image)
|
||||
for (const s of Object.keys(project.design?.suit_symbols || {})) {
|
||||
const sym = project.design.suit_symbols[s]
|
||||
if (sym?.type === 'image' && sym.asset_id) {
|
||||
// 由后端 /media 提供
|
||||
urls.add(`/media/${sym.value}`)
|
||||
}
|
||||
}
|
||||
for (const a of project.assets || []) {
|
||||
if (a.file_url) urls.add(a.file_url)
|
||||
}
|
||||
await Promise.all(Array.from(urls).map(u => loadImage(u)))
|
||||
}
|
||||
|
||||
function assetByType(project, type, key) {
|
||||
// 取最新一张匹配 (type, key) 的素材
|
||||
if (!project.assets) return null
|
||||
for (const a of project.assets) {
|
||||
if (a.asset_type === type && a.asset_key === key) return a
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ---------- 绘图原语 ---------- */
|
||||
|
||||
function drawRoundedRect(ctx, x, y, w, h, r) {
|
||||
if (r > w / 2) r = w / 2
|
||||
if (r > h / 2) r = h / 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r)
|
||||
ctx.arcTo(x, y + h, x, y, r)
|
||||
ctx.arcTo(x, y, x + w, y, r)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawBackground(ctx, w, h, design) {
|
||||
// 1. 底色
|
||||
ctx.fillStyle = design.background_color || '#FFFFFF'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
// 2. 背景图(保持比例铺满)
|
||||
if (design.background_image) {
|
||||
const img = imageCache.get(design.background_image)
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
const ratio = Math.max(w / img.naturalWidth, h / img.naturalHeight)
|
||||
const dw = img.naturalWidth * ratio
|
||||
const dh = img.naturalHeight * ratio
|
||||
ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBorder(ctx, w, h, design) {
|
||||
const width = Number(design.border_width) || 0
|
||||
if (width <= 0) return
|
||||
ctx.save()
|
||||
ctx.strokeStyle = design.border_color || '#333333'
|
||||
ctx.lineWidth = width
|
||||
const half = width / 2
|
||||
drawRoundedRect(ctx, half, half, w - width, h - width, 16)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawCornerIndex(ctx, w, h, suit, rank, design) {
|
||||
const cornerRatio = Number(design.corner_size_ratio) || 0.13
|
||||
const pad = Math.max(10, w * 0.045)
|
||||
const color = (design.suit_symbols?.[suit]?.color)
|
||||
|| (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade)
|
||||
|
||||
// rank 在上、suit 在下
|
||||
const rankSize = Math.round(w * cornerRatio * 1.05)
|
||||
const suitSize = Math.round(w * cornerRatio * 0.9)
|
||||
const fontFamily = design.font_family || 'Times New Roman'
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = color
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
// 左上
|
||||
ctx.font = `bold ${rankSize}px ${fontFamily}, serif`
|
||||
ctx.fillText(String(rank), pad, pad)
|
||||
ctx.font = `${suitSize}px Arial, sans-serif`
|
||||
const rankHeight = rankSize
|
||||
ctx.fillText(SUIT_TEXT[suit], pad, pad + rankHeight + 2)
|
||||
|
||||
// 估算左上班块高度
|
||||
const rankW = ctx.measureText(String(rank)).width
|
||||
const suitW = ctx.measureText(SUIT_TEXT[suit]).width
|
||||
const blockW = Math.max(rankW, suitW) + 8
|
||||
const blockH = rankHeight + 2 + suitSize + 8
|
||||
|
||||
// 右下:把左上班块平移 + 旋转 180°,再贴到右下
|
||||
ctx.save()
|
||||
ctx.translate(w - pad, h - pad)
|
||||
ctx.rotate(Math.PI)
|
||||
// 在新坐标系中绘制(左上)
|
||||
ctx.fillStyle = color
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.font = `bold ${rankSize}px ${fontFamily}, serif`
|
||||
ctx.fillText(String(rank), 0, 0)
|
||||
ctx.font = `${suitSize}px Arial, sans-serif`
|
||||
ctx.fillText(SUIT_TEXT[suit], 0, rankHeight + 2)
|
||||
ctx.restore()
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
async function drawSuitSymbol(ctx, x, y, size, suit, design) {
|
||||
const sym = design.suit_symbols?.[suit] || {}
|
||||
if (sym.type === 'image' && sym.asset_id) {
|
||||
const url = `/media/${sym.value}`
|
||||
const img = imageCache.get(url)
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
ctx.drawImage(img, x - size / 2, y - size / 2, size, size)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 文本符号
|
||||
ctx.save()
|
||||
ctx.fillStyle = sym.color || (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade)
|
||||
ctx.font = `${Math.round(size)}px Arial, "Segoe UI Symbol", sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(SUIT_TEXT[suit], x, y)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
async function drawNumberCardBody(ctx, w, h, suit, rank, design, project) {
|
||||
const pipRatio = Number(design.pip_size_ratio) || 0.16
|
||||
const pipSize = Math.max(40, w * pipRatio)
|
||||
const positions = computeNumberPipPositions(rank, w, h, pipSize, project.number_layout)
|
||||
for (const p of positions) {
|
||||
await drawSuitSymbol(ctx, p.x, p.y, p.size, suit, design)
|
||||
}
|
||||
}
|
||||
|
||||
async function drawFaceCardBody(ctx, w, h, suit, rank, design, project) {
|
||||
// 主体区域
|
||||
const padX = w * 0.15
|
||||
const padTop = h * 0.13
|
||||
const padBot = h * 0.13
|
||||
const bodyW = w - 2 * padX
|
||||
const bodyH = h - padTop - padBot
|
||||
|
||||
const cardKey = `${suit}-${rank}`
|
||||
const asset = assetByType(project, 'face_card', cardKey)
|
||||
let img = null
|
||||
if (asset?.file_url) {
|
||||
img = imageCache.get(asset.file_url) || null
|
||||
if (img) await loadImage(asset.file_url)
|
||||
}
|
||||
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
// 等比缩放 fill body
|
||||
const ratio = img.naturalWidth / img.naturalHeight
|
||||
const target = bodyW / bodyH
|
||||
let drawW, drawH
|
||||
if (ratio > target) {
|
||||
drawW = bodyW
|
||||
drawH = bodyW / ratio
|
||||
} else {
|
||||
drawH = bodyH
|
||||
drawW = bodyH * ratio
|
||||
}
|
||||
const drawX = padX + (bodyW - drawW) / 2
|
||||
const drawY = padTop + (bodyH - drawH) / 2
|
||||
|
||||
// 上下对称:先画上半,再把上半翻转贴到下半
|
||||
const halfH = drawH / 2
|
||||
ctx.save()
|
||||
// 上半(先画完整图,用 clip 限定为上半)
|
||||
ctx.beginPath()
|
||||
ctx.rect(drawX, drawY, drawW, halfH)
|
||||
ctx.clip()
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH)
|
||||
ctx.restore()
|
||||
// 下半:把原图翻转 180° 贴到下半区域
|
||||
ctx.save()
|
||||
ctx.translate(drawX + drawW, drawY + drawH)
|
||||
ctx.rotate(Math.PI)
|
||||
// 现在画完整图(经过旋转坐标系),让它 fill bodyW x halfH
|
||||
const tmpW = drawW
|
||||
const tmpH = halfH
|
||||
// 由于旋转,drawImage 时 x/y 是反向的;目标 = (0,0) 到 (drawW, halfH)
|
||||
// 经过 180° 旋转,相当于在 (drawX, drawY) + (drawW, drawH) 处映射为 (-drawW, -drawH) 到 (0, 0)
|
||||
// 所以让图片左上角对应 (0,0) 即可
|
||||
ctx.drawImage(img, 0, 0, tmpW, tmpH * 2) // 0,0 -> drawW,halfH (after 180 rotation)
|
||||
ctx.restore()
|
||||
} else {
|
||||
// 退化:绘制大花色 + 字母
|
||||
const big = Math.round(h * 0.30)
|
||||
const color = (design.suit_symbols?.[suit]?.color)
|
||||
|| (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade)
|
||||
ctx.save()
|
||||
ctx.fillStyle = color
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = `bold ${big}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText(rank, w / 2, padTop + bodyH * 0.35)
|
||||
ctx.font = `${big}px Arial, sans-serif`
|
||||
ctx.fillText(SUIT_TEXT[suit], w / 2, padTop + bodyH * 0.65)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
async function drawJokerBody(ctx, w, h, which, design, project) {
|
||||
// 背景色
|
||||
const bg = design.background_color
|
||||
|| (which === 'big' ? '#1B5E20' : '#B71C1C')
|
||||
ctx.fillStyle = bg
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
const padX = w * 0.15
|
||||
const padTop = h * 0.18
|
||||
const padBot = h * 0.22
|
||||
const bodyW = w - 2 * padX
|
||||
const bodyH = h - padTop - padBot
|
||||
|
||||
const asset = assetByType(project, 'joker', which)
|
||||
let img = null
|
||||
if (asset?.file_url) {
|
||||
img = imageCache.get(asset.file_url) || null
|
||||
if (img) await loadImage(asset.file_url)
|
||||
}
|
||||
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
const ratio = img.naturalWidth / img.naturalHeight
|
||||
const target = bodyW / bodyH
|
||||
let drawW, drawH
|
||||
if (ratio > target) {
|
||||
drawW = bodyW; drawH = bodyW / ratio
|
||||
} else {
|
||||
drawH = bodyH; drawW = bodyH * ratio
|
||||
}
|
||||
ctx.drawImage(img, padX + (bodyW - drawW) / 2, padTop + (bodyH - drawH) / 2, drawW, drawH)
|
||||
} else {
|
||||
// 退化
|
||||
const big = Math.round(h * 0.25)
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = `bold ${big}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText('JOKER', w / 2, h / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 角标
|
||||
const label = which === 'big' ? 'BIG' : 'SMALL'
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'top'
|
||||
const labelSize = Math.max(20, Math.round(w * 0.06))
|
||||
const textSize = Math.max(16, Math.round(w * 0.045))
|
||||
const pad = Math.max(10, w * 0.04)
|
||||
ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText('JOKER', pad, pad)
|
||||
ctx.fillText(label, pad, pad + textSize + 4)
|
||||
// 右下:旋转 180° 平移
|
||||
ctx.translate(w - pad, h - pad)
|
||||
ctx.rotate(Math.PI)
|
||||
ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText('JOKER', 0, 0)
|
||||
ctx.fillText(label, 0, textSize + 4)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawBackSide(ctx, w, h, design) {
|
||||
ctx.fillStyle = design.background_color || '#1A237E'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
ctx.save()
|
||||
ctx.strokeStyle = design.border_color || '#FFFFFF'
|
||||
ctx.lineWidth = 6
|
||||
const m = w * 0.06
|
||||
drawRoundedRect(ctx, m, m, w - 2 * m, h - 2 * m, 16)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = design.border_color || '#FFFFFF'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = `bold ${Math.round(w * 0.1)}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText('CARD BACK', w / 2, h / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/* ---------- 公开 API ---------- */
|
||||
|
||||
/**
|
||||
* 渲染单张牌到 canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {object} project
|
||||
* @param {string} cardKey e.g. 'spade-A', 'joker-big', 'back'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function renderCard(canvas, project, cardKey) {
|
||||
if (!canvas) return
|
||||
// 适配高 DPI
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = CARD_W
|
||||
const h = CARD_H
|
||||
if (canvas.width !== w * dpr) {
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
canvas.style.width = '300px'
|
||||
canvas.style.height = '420px'
|
||||
}
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 预加载可能用到的图片
|
||||
await preloadAll(project)
|
||||
|
||||
const design = getEffectiveDesign(project, cardKey)
|
||||
|
||||
if (isJoker(cardKey)) {
|
||||
const which = cardKey.split('-', 2)[1]
|
||||
await drawJokerBody(ctx, w, h, which, design, project)
|
||||
drawBorder(ctx, w, h, design)
|
||||
} else if (cardKey === 'back') {
|
||||
drawBackSide(ctx, w, h, design)
|
||||
} else {
|
||||
const [suit, rank] = cardKey.split('-')
|
||||
drawBackground(ctx, w, h, design)
|
||||
drawBorder(ctx, w, h, design)
|
||||
if (isFace(rank)) {
|
||||
await drawFaceCardBody(ctx, w, h, suit, rank, design, project)
|
||||
} else {
|
||||
await drawNumberCardBody(ctx, w, h, suit, rank, design, project)
|
||||
}
|
||||
drawCornerIndex(ctx, w, h, suit, rank, design)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染缩略图(小尺寸)到底部卡片列表
|
||||
* 简化版:缩放渲染,不画边框等细节
|
||||
*/
|
||||
export async function renderThumbnail(canvas, project, cardKey, thumbW = 80, thumbH = 112) {
|
||||
if (!canvas) return
|
||||
if (canvas.width !== thumbW) {
|
||||
canvas.width = thumbW
|
||||
canvas.height = thumbH
|
||||
}
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, thumbW, thumbH)
|
||||
// 用单独的小画布渲染再缩放
|
||||
const tmp = document.createElement('canvas')
|
||||
tmp.width = CARD_W
|
||||
tmp.height = CARD_H
|
||||
await renderCard(tmp, project, cardKey)
|
||||
ctx.drawImage(tmp, 0, 0, thumbW, thumbH)
|
||||
}
|
||||
|
||||
export { CARD_W, CARD_H }
|
||||
@@ -1,364 +1,322 @@
|
||||
<template>
|
||||
<div style="min-height: 100vh; background: #1a1a2e; color: #eee; font-family: sans-serif;">
|
||||
<header style="background: #16213e; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<button @click="$router.push('/')" style="background: #333; color: #aaa; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">← 返回</button>
|
||||
<input v-model="pname" @blur="saveName" style="background: transparent; border: none; color: white; font-size: 18px; font-weight: bold; outline: none; width: 300px;" placeholder="项目名称">
|
||||
<div class="editor" v-if="store.project">
|
||||
<header class="topbar">
|
||||
<div class="left">
|
||||
<button class="back" @click="router.push('/')">← 返回</button>
|
||||
<input
|
||||
v-model="projectName"
|
||||
@blur="onNameBlur"
|
||||
@keydown.enter="$event.target.blur()"
|
||||
class="title"
|
||||
placeholder="项目名称"
|
||||
/>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button @click="doExportAll" style="padding: 8px 20px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer;">导出全部</button>
|
||||
<button @click="doExportSingle" style="padding: 8px 20px; background: #0f3460; color: #e94560; border: 1px solid #e94560; border-radius: 4px; cursor: pointer;">导出当前</button>
|
||||
<div class="right">
|
||||
<span class="save-status" v-if="saving">保存中…</span>
|
||||
<span class="save-status ok" v-else-if="lastSaved">已保存</span>
|
||||
<button class="ghost" @click="exportSingle">导出当前</button>
|
||||
<button class="primary" @click="exportAll">导出整副牌 (ZIP)</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="display: flex; height: calc(100vh - 60px);">
|
||||
<aside style="width: 260px; background: #0f3460; padding: 20px; overflow-y: auto;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #888;">花色选择</h3>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 25px;">
|
||||
<button v-for="s in suits" :key="s"
|
||||
@click="switchSuit(s)"
|
||||
:style="{ padding: '8px 14px', border: 'none', borderRadius: '4px', cursor: 'pointer', background: currentSuit === s ? '#e94560' : '#16213e', color: currentSuit === s ? 'white' : '#aaa', fontWeight: currentSuit === s ? 'bold' : 'normal' }">
|
||||
{{ suitLabels[s] }}
|
||||
</button>
|
||||
<div class="main">
|
||||
<!-- 左侧:牌面选择 + 编辑面板 -->
|
||||
<aside class="left-pane">
|
||||
<div class="card-tabs">
|
||||
<button
|
||||
v-for="g in cardGroups" :key="g.id"
|
||||
:class="['tab', { active: currentGroup === g.id }]"
|
||||
@click="currentGroup = g.id"
|
||||
>{{ g.label }}</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #888;">图层管理</h3>
|
||||
<div v-for="l in layers" :key="l.id"
|
||||
@click="toggleLayer(l)"
|
||||
:style="{ padding: '10px 12px', marginBottom: '5px', background: activeLayer === l.id ? '#16213e' : '#0f3460', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', borderLeft: activeLayer === l.id ? '3px solid #e94560' : '3px solid transparent', transition: 'all 0.2s' }">
|
||||
<span style="width: 20px; text-align: center;">{{ l.visible ? '👁' : '—' }}</span>
|
||||
<span style="font-size: 13px; flex: 1;">{{ l.name }}</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px;">
|
||||
<button @click.stop="moveLayer(l, -1)" :disabled="l.zIndex <= 0"
|
||||
:style="{ border: 'none', background: 'transparent', color: l.zIndex <= 0 ? '#333' : '#888', cursor: l.zIndex <= 0 ? 'default' : 'pointer', fontSize: '10px', padding: '0' }">▲</button>
|
||||
<button @click.stop="moveLayer(l, 1)" :disabled="l.zIndex >= layers.length - 1"
|
||||
:style="{ border: 'none', background: 'transparent', color: l.zIndex >= layers.length - 1 ? '#333' : '#888', cursor: l.zIndex >= layers.length - 1 ? 'default' : 'pointer', fontSize: '10px', padding: '0' }">▼</button>
|
||||
</span>
|
||||
<div class="card-list" v-if="currentGroup !== 'back'">
|
||||
<div class="card-row">
|
||||
<span class="row-label">花色</span>
|
||||
<button v-for="s in suits" :key="s"
|
||||
:class="['suit-btn', suitOfCurrent, { active: suitOfCurrent === s }]"
|
||||
@click="switchSuit(s)">{{ suitSymbol(s) }}</button>
|
||||
</div>
|
||||
<div class="rank-grid">
|
||||
<button
|
||||
v-for="c in cardsInGroup" :key="c.key"
|
||||
:class="['card-cell', { active: store.currentCard === c.key }]"
|
||||
@click="store.currentCard = c.key"
|
||||
>{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-list" v-else>
|
||||
<button class="card-cell" :class="{ active: store.currentCard === 'back' }"
|
||||
@click="store.currentCard = 'back'">背面</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin: 25px 0 12px 0; font-size: 14px; color: #888;">属性编辑</h3>
|
||||
<div v-if="activeLayer === 'bg'" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<label style="font-size: 12px; color: #aaa;">背景颜色(实时预览)</label>
|
||||
<input v-model="bgColor" type="color" @input="onBgColorChange" style="width: 100%; height: 32px; border: none; border-radius: 4px; cursor: pointer;">
|
||||
|
||||
<div style="border-top: 1px solid #333; margin: 4px 0;"></div>
|
||||
|
||||
<label style="font-size: 12px; color: #aaa;">上传背景图片</label>
|
||||
<input ref="bgFileInput" type="file" accept="image/*" @change="onBgImageUpload" style="display: none;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button @click="triggerBgUpload" style="flex: 1; padding: 8px 12px; background: #16213e; color: #aaa; border: 1px solid #444; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap;">选择图片</button>
|
||||
<button v-if="bgImageUrl" @click="clearBgImage" style="padding: 8px 12px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">清除</button>
|
||||
</div>
|
||||
|
||||
<div v-if="bgImageUrl" style="margin-top: 4px;">
|
||||
<div style="font-size: 10px; color: #666; margin-bottom: 4px;">图片预览:</div>
|
||||
<img :src="bgImageUrl" style="width: 100%; max-height: 100px; object-fit: contain; border-radius: 4px; border: 1px solid #333;">
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeLayer === 'border'" style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<label style="font-size: 12px; color: #aaa;">边框颜色(实时)</label>
|
||||
<input v-model="borderColor" type="color" @input="onBorderColorChange" style="width: 100%; height: 32px; border: none; border-radius: 4px; cursor: pointer;">
|
||||
<label style="font-size: 12px; color: #aaa;">边框粗细: {{ borderWidth }}px</label>
|
||||
<input v-model="borderWidth" type="range" min="0" max="20" step="1" @input="onBorderChange" style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
<div v-if="activeLayer === 'text'" style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<label style="font-size: 12px; color: #aaa;">字体大小(实时): {{ textSize }}px</label>
|
||||
<input v-model="textSize" type="range" min="12" max="120" step="2" @input="onTextSizeChange" style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
<div v-if="!['bg','border','text','pattern'].includes(activeLayer)" style="color: #888; font-size: 12px; padding: 10px;">
|
||||
选中画布上的对象查看属性
|
||||
</div>
|
||||
<div class="panel-title">设计编辑</div>
|
||||
<DesignPanel />
|
||||
</aside>
|
||||
|
||||
<main style="flex: 1; display: flex; justify-content: center; align-items: center; background: #1a1a2e; padding: 20px;">
|
||||
<div style="background: #16213e; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.3);">
|
||||
<canvas ref="canvasEl" id="main-canvas"></canvas>
|
||||
<!-- 中间:主画布 -->
|
||||
<main class="canvas-pane">
|
||||
<div class="canvas-wrap">
|
||||
<canvas ref="mainCanvas" id="main-canvas"></canvas>
|
||||
</div>
|
||||
<div class="canvas-hint">
|
||||
<span v-if="currentInfo">编辑中:{{ currentInfo }}</span>
|
||||
<span v-else>选择一张牌开始编辑</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧:素材管理 -->
|
||||
<aside class="right-pane">
|
||||
<AssetPanel />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer style="background: #0f3460; padding: 10px 20px; display: flex; gap: 5px; overflow-x: auto;">
|
||||
<div v-for="c in currentCards" :key="c.key"
|
||||
@click="selectCard(c.key)"
|
||||
:style="{ minWidth: '60px', height: '84px', background: currentCard === c.key ? '#e94560' : '#16213e', color: currentCard === c.key ? 'white' : '#aaa', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: '12px', fontWeight: currentCard === c.key ? 'bold' : 'normal', flexShrink: 0, transition: 'all 0.2s' }">
|
||||
{{ c.label }}
|
||||
<!-- 底部:54 张缩略图 -->
|
||||
<footer class="bottom-pane">
|
||||
<div class="thumb-list">
|
||||
<div
|
||||
v-for="c in allThumbs" :key="c.key"
|
||||
:class="['thumb', { active: store.currentCard === c.key }]"
|
||||
@click="store.currentCard = c.key"
|
||||
>
|
||||
<canvas :ref="el => thumbRefs[c.key] = el" :width="60" :height="84"></canvas>
|
||||
<div class="thumb-label">{{ c.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<div v-else class="loading">加载项目中…</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Canvas, FabricText, Rect, FabricImage } from 'fabric'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectStore } from '@/stores/projectStore'
|
||||
import { renderCard, renderThumbnail } from '@/utils/cardRenderer'
|
||||
import { SUITS, SUIT_TEXT, listAllCards, isJoker } from '@/utils/cardLayout'
|
||||
import DesignPanel from '@/components/DesignPanel.vue'
|
||||
import AssetPanel from '@/components/AssetPanel.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { project, currentCard } = storeToRefs(store)
|
||||
|
||||
const canvasEl = ref(null)
|
||||
const pname = ref('')
|
||||
const currentSuit = ref('spade')
|
||||
const currentCard = ref('spade-A')
|
||||
const fabricCanvas = ref(null)
|
||||
const activeLayer = ref('bg')
|
||||
const projectId = computed(() => route.params.projectId)
|
||||
const projectName = ref('')
|
||||
const saving = ref(false)
|
||||
const lastSaved = ref(false)
|
||||
const currentGroup = ref('number')
|
||||
const mainCanvas = ref(null)
|
||||
const thumbRefs = ref({})
|
||||
let saveWatchStop = null
|
||||
|
||||
const suits = ['spade', 'heart', 'club', 'diamond']
|
||||
const suitLabels = { spade: '♠ 黑桃', heart: '♥ 红桃', club: '♣ 梅花', diamond: '♦ 方块' }
|
||||
|
||||
const ranks = ['A','2','3','4','5','6','7','8','9','10','J','Q','K']
|
||||
|
||||
const currentCards = computed(() => {
|
||||
const cards = ranks.map(r => ({ key: `${currentSuit.value}-${r}`, label: getSymbol(currentSuit.value) + r }))
|
||||
if (currentSuit.value === 'spade') {
|
||||
cards.push({ key: 'joker-big', label: '大王' })
|
||||
cards.push({ key: 'joker-small', label: '小王' })
|
||||
}
|
||||
return cards
|
||||
const suits = SUITS
|
||||
const suitSymbol = (s) => SUIT_TEXT[s]
|
||||
const suitOfCurrent = computed(() => {
|
||||
if (!currentCard.value || !currentCard.value.includes('-')) return 'spade'
|
||||
const [s] = currentCard.value.split('-')
|
||||
return s
|
||||
})
|
||||
|
||||
const layers = ref([
|
||||
{ id: 'bg', name: '背景层', visible: true, zIndex: 0, fillColor: '#ffffff', imageData: null },
|
||||
{ id: 'border', name: '边框层', visible: true, zIndex: 1, strokeColor: '#333333', strokeWidth: 2 },
|
||||
{ id: 'pattern', name: '图案层', visible: true, zIndex: 2 },
|
||||
{ id: 'text', name: '文字层', visible: true, zIndex: 3, textSize: 32 }
|
||||
])
|
||||
const cardGroups = [
|
||||
{ id: 'number', label: '数字牌' },
|
||||
{ id: 'face', label: 'JQK' },
|
||||
{ id: 'joker', label: '大小王' },
|
||||
{ id: 'back', label: '背面' },
|
||||
]
|
||||
|
||||
const bgColor = ref('#ffffff')
|
||||
const bgImageUrl = ref(null)
|
||||
const borderColor = ref('#333333')
|
||||
const borderWidth = ref(2)
|
||||
const textSize = ref(32)
|
||||
|
||||
const bgFileInput = ref(null)
|
||||
|
||||
function triggerBgUpload() {
|
||||
bgFileInput.value?.click()
|
||||
}
|
||||
|
||||
function onBgImageUpload(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
bgImageUrl.value = ev.target.result
|
||||
const l = layers.value.find(x => x.id === 'bg')
|
||||
if (l) l.imageData = ev.target.result
|
||||
drawCard()
|
||||
const cardsInGroup = computed(() => {
|
||||
const s = suitOfCurrent.value
|
||||
if (currentGroup.value === 'number') {
|
||||
return ['A','2','3','4','5','6','7','8','9','10'].map(r => ({
|
||||
key: `${s}-${r}`,
|
||||
label: `${SUIT_TEXT[s]}${r}`,
|
||||
}))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function clearBgImage() {
|
||||
bgImageUrl.value = null
|
||||
const l = layers.value.find(x => x.id === 'bg')
|
||||
if (l) l.imageData = null
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function onBgColorChange() {
|
||||
const l = layers.value.find(x => x.id === 'bg')
|
||||
if (l) l.fillColor = bgColor.value
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function toggleLayer(layer) {
|
||||
activeLayer.value = layer.id
|
||||
layer.visible = !layer.visible
|
||||
syncLayerProps()
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function syncLayerProps() {
|
||||
const bgLayer = layers.value.find(x => x.id === 'bg')
|
||||
if (bgLayer) {
|
||||
bgColor.value = bgLayer.fillColor
|
||||
bgImageUrl.value = bgLayer.imageData
|
||||
if (currentGroup.value === 'face') {
|
||||
return ['J','Q','K'].map(r => ({
|
||||
key: `${s}-${r}`,
|
||||
label: `${SUIT_TEXT[s]}${r}`,
|
||||
}))
|
||||
}
|
||||
const bLayer = layers.value.find(x => x.id === 'border')
|
||||
if (bLayer) {
|
||||
borderColor.value = bLayer.strokeColor
|
||||
borderWidth.value = bLayer.strokeWidth
|
||||
if (currentGroup.value === 'joker') {
|
||||
return [
|
||||
{ key: 'joker-big', label: '大王' },
|
||||
{ key: 'joker-small', label: '小王' },
|
||||
]
|
||||
}
|
||||
const tLayer = layers.value.find(x => x.id === 'text')
|
||||
if (tLayer) {
|
||||
textSize.value = tLayer.textSize
|
||||
}
|
||||
}
|
||||
|
||||
function onBorderColorChange() {
|
||||
const l = layers.value.find(x => x.id === 'border')
|
||||
if (l) l.strokeColor = borderColor.value
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function onBorderChange() {
|
||||
const l = layers.value.find(x => x.id === 'border')
|
||||
if (l) { l.strokeColor = borderColor.value; l.strokeWidth = parseInt(borderWidth.value) }
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function onTextSizeChange() {
|
||||
const l = layers.value.find(x => x.id === 'text')
|
||||
if (l) l.textSize = parseInt(textSize.value)
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function getSymbol(suit) {
|
||||
return { spade: '♠', heart: '♥', club: '♣', diamond: '♦' }[suit] || ''
|
||||
}
|
||||
|
||||
function isRed(suit) {
|
||||
return suit === 'heart' || suit === 'diamond'
|
||||
}
|
||||
|
||||
function moveLayer(layer, delta) {
|
||||
const newZ = layer.zIndex + delta
|
||||
const swapped = layers.value.find(l => l.zIndex === newZ)
|
||||
if (swapped) {
|
||||
swapped.zIndex = layer.zIndex
|
||||
layer.zIndex = newZ
|
||||
layers.value.sort((a, b) => a.zIndex - b.zIndex)
|
||||
drawCard()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (projectId.value) {
|
||||
try {
|
||||
const res = await axios.get(`/api/projects/${projectId.value}/`)
|
||||
pname.value = res.data.name
|
||||
} catch (e) {
|
||||
console.error('Failed to load project:', e)
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
initCanvas()
|
||||
return []
|
||||
})
|
||||
|
||||
function initCanvas() {
|
||||
if (!canvasEl.value) return
|
||||
fabricCanvas.value = new Canvas('main-canvas', {
|
||||
width: 400,
|
||||
height: 560,
|
||||
backgroundColor: '#f5f5f5',
|
||||
selection: true
|
||||
})
|
||||
|
||||
fabricCanvas.value.on('selection:created', (e) => {
|
||||
const obj = e.selected[0]
|
||||
if (obj && obj.layerId) {
|
||||
activeLayer.value = obj.layerId
|
||||
syncLayerProps()
|
||||
}
|
||||
})
|
||||
|
||||
fabricCanvas.value.on('selection:updated', (e) => {
|
||||
const obj = e.selected[0]
|
||||
if (obj && obj.layerId) {
|
||||
activeLayer.value = obj.layerId
|
||||
syncLayerProps()
|
||||
}
|
||||
})
|
||||
|
||||
drawCard()
|
||||
}
|
||||
|
||||
function drawCard() {
|
||||
const c = fabricCanvas.value
|
||||
if (!c) return
|
||||
|
||||
c.clear()
|
||||
|
||||
const sortedLayers = [...layers.value].sort((a, b) => a.zIndex - b.zIndex)
|
||||
const suit = currentSuit.value
|
||||
const rank = currentCard.value.split('-')[1] || 'A'
|
||||
const sym = getSymbol(suit)
|
||||
const color = isRed(suit) ? '#FF0000' : '#000000'
|
||||
const cardW = 400
|
||||
const cardH = 560
|
||||
|
||||
for (const layer of sortedLayers) {
|
||||
if (!layer.visible) continue
|
||||
|
||||
if (layer.id === 'bg') {
|
||||
if (layer.imageData) {
|
||||
const bgImg = new Image()
|
||||
bgImg.crossOrigin = 'anonymous'
|
||||
bgImg.onload = () => {
|
||||
const fImg = new FabricImage(bgImg, { left: 0, top: 0, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true })
|
||||
fImg.scaleX = cardW / bgImg.width
|
||||
fImg.scaleY = cardH / bgImg.height
|
||||
fImg.layerId = 'bg'
|
||||
c.insertAt(0, fImg)
|
||||
c.renderAll()
|
||||
}
|
||||
bgImg.src = layer.imageData
|
||||
}
|
||||
const bgRect = new Rect({ left: 0, top: 0, width: cardW, height: cardH, fill: layer.imageData ? 'transparent' : (layer.fillColor || '#ffffff'), selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true, stroke: 'transparent', strokeWidth: 0 })
|
||||
bgRect.layerId = 'bg'
|
||||
c.add(bgRect)
|
||||
}
|
||||
|
||||
if (layer.id === 'border') {
|
||||
const frame = new Rect({ left: 10, top: 10, width: cardW - 20, height: cardH - 20, fill: 'transparent', stroke: layer.strokeColor || '#333', strokeWidth: layer.strokeWidth || 2, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true })
|
||||
frame.layerId = 'border'
|
||||
c.add(frame)
|
||||
}
|
||||
|
||||
if (layer.id === 'pattern') {
|
||||
const center = new FabricText(sym, {
|
||||
left: cardW / 2, top: cardH / 2, fontSize: 80, fill: color, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true, originX: 'center', originY: 'center'
|
||||
})
|
||||
center.layerId = 'pattern'
|
||||
c.add(center)
|
||||
}
|
||||
|
||||
if (layer.id === 'text') {
|
||||
const ts = layer.textSize || 32
|
||||
const topLabel = new FabricText(`${rank}${sym}`, {
|
||||
left: 18, top: 16, fontSize: ts, fill: color, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true
|
||||
})
|
||||
topLabel.layerId = 'text'
|
||||
c.add(topLabel)
|
||||
|
||||
const bottomLabel = new FabricText(`${sym}${rank}`, {
|
||||
left: cardW - 18, top: cardH - 16, fontSize: ts, fill: color, selectable: true, hasControls: false, lockMovementX: true, lockMovementY: true, originX: 'right', originY: 'bottom'
|
||||
})
|
||||
bottomLabel.layerId = 'text'
|
||||
c.add(bottomLabel)
|
||||
const allThumbs = computed(() => {
|
||||
// 按 spade/heart/club/diamond × 13 + 大小王 + 背
|
||||
const out = []
|
||||
for (const s of SUITS) {
|
||||
for (const r of ['A','2','3','4','5','6','7','8','9','10','J','Q','K']) {
|
||||
out.push({ key: `${s}-${r}`, label: `${SUIT_TEXT[s]}${r}` })
|
||||
}
|
||||
}
|
||||
out.push({ key: 'joker-big', label: '大' })
|
||||
out.push({ key: 'joker-small', label: '小' })
|
||||
out.push({ key: 'back', label: '背' })
|
||||
return out
|
||||
})
|
||||
|
||||
c.renderAll()
|
||||
}
|
||||
const currentInfo = computed(() => {
|
||||
if (!currentCard.value) return ''
|
||||
if (isJoker(currentCard.value)) {
|
||||
return currentCard.value === 'joker-big' ? '大王' : '小王'
|
||||
}
|
||||
if (currentCard.value === 'back') return '牌面背面'
|
||||
return currentCard.value.replace('-', ' ').toUpperCase()
|
||||
})
|
||||
|
||||
function switchSuit(s) {
|
||||
currentSuit.value = s
|
||||
currentCard.value = `${s}-A`
|
||||
drawCard()
|
||||
const cur = store.currentCard
|
||||
if (cur && cur.includes('-')) {
|
||||
const [, r] = cur.split('-')
|
||||
const isFace = r === 'J' || r === 'Q' || r === 'K'
|
||||
if (isFace && currentGroup.value !== 'face') {
|
||||
store.currentCard = `${s}-A`
|
||||
} else {
|
||||
store.currentCard = `${s}-${r}`
|
||||
}
|
||||
} else {
|
||||
store.currentCard = `${s}-A`
|
||||
}
|
||||
}
|
||||
|
||||
function selectCard(key) {
|
||||
currentCard.value = key
|
||||
drawCard()
|
||||
}
|
||||
watch(currentCard, () => { drawAll() })
|
||||
watch(() => project.value && project.value.assets, () => { drawAll() }, { deep: true })
|
||||
watch(() => project.value && project.value.design, () => { drawAll() }, { deep: true })
|
||||
watch(() => project.value && project.value.card_overrides, () => { drawAll() }, { deep: true })
|
||||
watch(() => project.value && project.value.number_layout, () => { drawAll() }, { deep: true })
|
||||
|
||||
async function saveName() {
|
||||
if (!projectId.value) return
|
||||
try { await axios.put(`/api/projects/${projectId.value}/`, { name: pname.value }) } catch (e) {}
|
||||
}
|
||||
|
||||
async function doExportAll() {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await axios.post(`/api/projects/${projectId.value}/export/`, { resolution: 'standard', cards: 'all' })
|
||||
if (res.data.download_url) window.open(res.data.download_url, '_blank')
|
||||
} catch (e) { alert('导出失败: ' + e.message) }
|
||||
await store.loadProject(route.params.projectId)
|
||||
projectName.value = project.value.name
|
||||
await nextTick()
|
||||
drawAll()
|
||||
// 自动保存指示器
|
||||
saveWatchStop = watch(
|
||||
() => [project.value.design, project.value.card_overrides, project.value.number_layout],
|
||||
() => { markSaving() },
|
||||
{ deep: true }
|
||||
)
|
||||
} catch (e) {
|
||||
alert('加载项目失败: ' + e.message)
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { if (saveWatchStop) saveWatchStop() })
|
||||
|
||||
function markSaving() {
|
||||
saving.value = true
|
||||
lastSaved.value = false
|
||||
setTimeout(() => { saving.value = false; lastSaved.value = true }, 700)
|
||||
setTimeout(() => { lastSaved.value = false }, 2500)
|
||||
}
|
||||
|
||||
function doExportSingle() {
|
||||
const url = `/api/projects/${projectId.value}/export/${currentCard.value}/?resolution=standard`
|
||||
window.open(url, '_blank')
|
||||
async function drawAll() {
|
||||
if (!project.value) return
|
||||
// 主画布
|
||||
if (mainCanvas.value) {
|
||||
await renderCard(mainCanvas.value, project.value, currentCard.value)
|
||||
}
|
||||
// 缩略图
|
||||
for (const c of allThumbs.value) {
|
||||
const cv = thumbRefs.value[c.key]
|
||||
if (cv) await renderThumbnail(cv, project.value, c.key, 60, 84)
|
||||
}
|
||||
}
|
||||
|
||||
async function onNameBlur() {
|
||||
if (project.value && projectName.value && projectName.value !== project.value.name) {
|
||||
try { await store.saveName(projectName.value) } catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAll() {
|
||||
try {
|
||||
const r = await axios.post(`/api/projects/${project.value.id}/export/`,
|
||||
{ resolution: 'standard', cards: 'all' },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
download(r.data, `${project.value.name || 'cards'}.zip`)
|
||||
} catch (e) {
|
||||
alert('导出失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
async function exportSingle() {
|
||||
try {
|
||||
const r = await axios.get(`/api/projects/${project.value.id}/export/${currentCard.value}/?resolution=standard`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
download(r.data, `${currentCard.value}.png`)
|
||||
} catch (e) {
|
||||
alert('导出失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
function download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = filename
|
||||
document.body.appendChild(a); a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor { display: flex; flex-direction: column; height: 100vh; background: #1a1a2e; color: #eee; font-family: 'Microsoft YaHei', sans-serif; }
|
||||
.loading { display: flex; height: 100vh; align-items: center; justify-content: center; color: #aaa; }
|
||||
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #16213e; border-bottom: 1px solid #0f3460; }
|
||||
.topbar .left { display: flex; align-items: center; gap: 16px; }
|
||||
.topbar .right { display: flex; align-items: center; gap: 10px; }
|
||||
button { cursor: pointer; border: none; border-radius: 4px; padding: 6px 14px; font-size: 13px; }
|
||||
button.primary { background: #e94560; color: #fff; }
|
||||
button.primary:hover { background: #ff5577; }
|
||||
button.ghost { background: transparent; border: 1px solid #e94560; color: #e94560; }
|
||||
button.ghost:hover { background: rgba(233, 69, 96, 0.1); }
|
||||
button.back { background: #333; color: #aaa; }
|
||||
.title { background: transparent; border: none; color: white; font-size: 18px; font-weight: bold; outline: none; width: 280px; padding: 6px 8px; border-radius: 4px; }
|
||||
.title:focus { background: rgba(255,255,255,0.06); }
|
||||
.save-status { font-size: 12px; color: #888; margin-right: 4px; }
|
||||
.save-status.ok { color: #66bb6a; }
|
||||
|
||||
.main { flex: 1; display: flex; min-height: 0; }
|
||||
.left-pane { width: 280px; background: #0f3460; padding: 16px; overflow-y: auto; border-right: 1px solid #16213e; }
|
||||
.right-pane { width: 320px; background: #0f3460; padding: 16px; overflow-y: auto; border-left: 1px solid #16213e; }
|
||||
.canvas-pane { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; background: #1a1a2e; }
|
||||
|
||||
.card-tabs { display: flex; gap: 4px; margin-bottom: 12px; }
|
||||
.card-tabs .tab { flex: 1; background: #16213e; color: #aaa; padding: 8px 6px; font-size: 12px; border-radius: 4px; }
|
||||
.card-tabs .tab.active { background: #e94560; color: white; }
|
||||
|
||||
.card-list { background: #16213e; border-radius: 8px; padding: 10px; margin-bottom: 16px; }
|
||||
.card-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.card-row .row-label { font-size: 12px; color: #aaa; width: 30px; }
|
||||
.suit-btn { background: #0f3460; color: #aaa; padding: 6px 10px; font-size: 16px; border-radius: 4px; }
|
||||
.suit-btn.active { background: #e94560; color: white; }
|
||||
.rank-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
|
||||
.card-cell { background: #0f3460; color: #ccc; padding: 6px 4px; font-size: 12px; border-radius: 4px; }
|
||||
.card-cell.active { background: #e94560; color: white; }
|
||||
|
||||
.panel-title { font-size: 13px; color: #888; margin: 12px 0 8px 0; }
|
||||
|
||||
.canvas-wrap { background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.4); padding: 0; overflow: hidden; }
|
||||
#main-canvas { display: block; }
|
||||
.canvas-hint { margin-top: 12px; color: #aaa; font-size: 13px; }
|
||||
|
||||
.bottom-pane { background: #0f3460; padding: 8px; border-top: 1px solid #16213e; overflow-x: auto; }
|
||||
.thumb-list { display: flex; gap: 6px; padding: 4px 0; }
|
||||
.thumb { display: flex; flex-direction: column; align-items: center; cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s; }
|
||||
.thumb:hover { background: #16213e; }
|
||||
.thumb.active { background: #e94560; }
|
||||
.thumb canvas { display: block; background: #fff; border-radius: 3px; }
|
||||
.thumb-label { font-size: 10px; color: #aaa; margin-top: 2px; }
|
||||
.thumb.active .thumb-label { color: white; }
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,71 @@
|
||||
<template>
|
||||
<div style="min-height: 100vh; background: #1a1a2e; color: #eee; font-family: sans-serif;">
|
||||
<header style="background: #16213e; padding: 25px 40px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #e94560;">扑克牌设计管理系统</h1>
|
||||
<button @click="doCreate" style="padding: 12px 28px; background: #e94560; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; font-weight: bold;">
|
||||
创建新项目
|
||||
</button>
|
||||
<div class="home">
|
||||
<header class="topbar">
|
||||
<h1>扑克牌设计管理系统</h1>
|
||||
<button class="primary" @click="doCreate">+ 新建空白项目</button>
|
||||
</header>
|
||||
|
||||
<main style="max-width: 1200px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="margin-bottom: 30px; font-size: 22px;">选择模板系列开始设计</h2>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 50px;">
|
||||
<div v-for="t in templateList" :key="t.id"
|
||||
@click="createFromTemplate(t.id)"
|
||||
style="background: #0f3460; border-radius: 12px; padding: 25px; cursor: pointer; transition: all 0.3s; text-align: center;"
|
||||
@mouseenter="$event.currentTarget.style.background = '#16213e'"
|
||||
@mouseleave="$event.currentTarget.style.background = '#0f3460'">
|
||||
<div style="font-size: 48px; margin-bottom: 12px;">{{ t.icon }}</div>
|
||||
<h3 style="margin: 0 0 8px 0; font-size: 18px; color: #e94560;">{{ t.name }}</h3>
|
||||
<p style="margin: 0; font-size: 13px; color: #aaa; line-height: 1.5;">{{ t.desc }}</p>
|
||||
<main>
|
||||
<section>
|
||||
<h2>选择模板系列开始设计</h2>
|
||||
<div class="template-grid">
|
||||
<div v-for="t in templateList" :key="t.id"
|
||||
class="template-card"
|
||||
@click="createFromTemplate(t.id)">
|
||||
<div class="icon">{{ t.icon }}</div>
|
||||
<h3>{{ t.name }}</h3>
|
||||
<p>{{ t.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="hasProjects" style="background: #0f3460; border-radius: 12px; padding: 25px;">
|
||||
<h3 style="margin: 0 0 20px 0;">已有项目</h3>
|
||||
<div v-for="p in projectList" :key="p.id"
|
||||
style="display: flex; justify-content: space-between; align-items: center; padding: 15px; background: #16213e; border-radius: 8px; margin-bottom: 10px;">
|
||||
<section v-if="hasProjects" class="project-list">
|
||||
<h3>已有项目</h3>
|
||||
<div v-for="p in projects" :key="p.id" class="project-row">
|
||||
<div>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<div style="font-size: 12px; color: #888; margin-top: 4px;">创建于: {{ formatDate(p.created_at) }}</div>
|
||||
<div class="meta">
|
||||
模板 {{ p.template_id }} · 创建于 {{ formatDate(p.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button @click.stop="editProject(p.id)" style="padding: 6px 16px; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer;">编辑</button>
|
||||
<button @click.stop="removeProject(p.id)" style="padding: 6px 16px; background: #333; color: #aaa; border: none; border-radius: 4px; cursor: pointer;">删除</button>
|
||||
<div class="row-actions">
|
||||
<button class="primary" @click="editProject(p.id)">编辑</button>
|
||||
<button class="danger" @click="removeProject(p.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" style="text-align: center; padding: 40px; color: #888;">加载中...</div>
|
||||
<div v-if="loadError" style="text-align: center; padding: 40px; color: #e94560;">{{ loadError }}</div>
|
||||
<div v-if="loading" class="muted">加载中…</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectStore } from '@/stores/projectStore.js'
|
||||
|
||||
const router = useRouter()
|
||||
const projectList = ref([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const store = useProjectStore()
|
||||
const { projects, loading, error } = storeToRefs(store)
|
||||
|
||||
const templateList = [
|
||||
{ id: 'classic', name: '经典风格', desc: '标准扑克牌设计,传统花色和字体', icon: '♠' },
|
||||
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
|
||||
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像,圆润花色图案', icon: '★' },
|
||||
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' }
|
||||
{ id: 'modern', name: '现代简约', desc: '扁平化设计,简洁线条', icon: '◆' },
|
||||
{ id: 'cartoon', name: '卡通风格', desc: 'Q版可爱人像,圆润花色图案', icon: '★' },
|
||||
{ id: 'vintage', name: '复古风格', desc: '复古色调和纹理,装饰性边框', icon: '♛' },
|
||||
]
|
||||
|
||||
const hasProjects = computed(() => projectList.value.length > 0)
|
||||
const hasProjects = computed(() => projects.value.length > 0)
|
||||
|
||||
onMounted(() => {
|
||||
loadProjects()
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await axios.get('/api/projects/')
|
||||
projectList.value = res.data || []
|
||||
} catch (e) {
|
||||
loadError.value = '无法连接后端服务: ' + e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => store.fetchProjects())
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
const res = await axios.post('/api/projects/', {
|
||||
name: '新项目 ' + new Date().toLocaleDateString(),
|
||||
template_id: 'classic'
|
||||
})
|
||||
router.push('/editor/' + res.data.id)
|
||||
const p = await store.createProject('新项目 ' + new Date().toLocaleDateString())
|
||||
router.push('/editor/' + p.id)
|
||||
} catch (e) {
|
||||
alert('创建失败: ' + e.message)
|
||||
}
|
||||
@@ -94,11 +74,8 @@ async function doCreate() {
|
||||
async function createFromTemplate(tid) {
|
||||
try {
|
||||
const nm = templateList.find(t => t.id === tid)?.name || tid
|
||||
const res = await axios.post('/api/projects/', {
|
||||
name: nm + ' - 新项目',
|
||||
template_id: tid
|
||||
})
|
||||
router.push('/editor/' + res.data.id)
|
||||
const p = await store.createProject(nm + ' - ' + new Date().toLocaleDateString(), tid)
|
||||
router.push('/editor/' + p.id)
|
||||
} catch (e) {
|
||||
alert('创建失败: ' + e.message)
|
||||
}
|
||||
@@ -109,10 +86,9 @@ function editProject(id) {
|
||||
}
|
||||
|
||||
async function removeProject(id) {
|
||||
if (!confirm('确定删除?')) return
|
||||
if (!confirm('确定删除这个项目?')) return
|
||||
try {
|
||||
await axios.delete('/api/projects/' + id + '/')
|
||||
await loadProjects()
|
||||
await store.deleteProject(id)
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
@@ -122,3 +98,29 @@ function formatDate(d) {
|
||||
return new Date(d).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home { min-height: 100vh; background: #1a1a2e; color: #eee; font-family: 'Microsoft YaHei', sans-serif; }
|
||||
.topbar { background: #16213e; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.topbar h1 { margin: 0; font-size: 24px; color: #e94560; }
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 30px 20px; }
|
||||
section { margin-bottom: 36px; }
|
||||
h2 { margin: 0 0 18px 0; font-size: 18px; color: #ccc; }
|
||||
.template-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||
.template-card { background: #0f3460; border-radius: 12px; padding: 22px; cursor: pointer; transition: all 0.2s; text-align: center; }
|
||||
.template-card:hover { background: #16213e; transform: translateY(-2px); }
|
||||
.template-card .icon { font-size: 44px; margin-bottom: 10px; color: #e94560; }
|
||||
.template-card h3 { margin: 0 0 6px 0; font-size: 16px; }
|
||||
.template-card p { margin: 0; font-size: 12px; color: #aaa; line-height: 1.5; }
|
||||
.project-list { background: #0f3460; border-radius: 12px; padding: 22px; }
|
||||
.project-row { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #16213e; border-radius: 8px; margin-bottom: 8px; }
|
||||
.project-row .meta { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.row-actions { display: flex; gap: 8px; }
|
||||
button { border: none; border-radius: 4px; cursor: pointer; padding: 6px 14px; font-size: 13px; }
|
||||
button.primary { background: #e94560; color: white; }
|
||||
button.primary:hover { background: #ff5577; }
|
||||
button.danger { background: #333; color: #aaa; }
|
||||
button.danger:hover { background: #444; color: #fff; }
|
||||
.muted { color: #888; padding: 20px; text-align: center; }
|
||||
.error { color: #e94560; padding: 12px; text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -1,67 +1,126 @@
|
||||
<template>
|
||||
<div style="padding: 20px; font-family: Arial, sans-serif;">
|
||||
<h1>扑克牌设计管理系统 - 测试页面</h1>
|
||||
<p>如果你能看到这个页面,说明Vue正常工作</p>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h2>API测试</h2>
|
||||
<button @click="testAPI" style="padding: 10px 20px; cursor: pointer;">
|
||||
测试API连接
|
||||
</button>
|
||||
<p v-if="apiResult">API返回: {{ apiResult }}</p>
|
||||
<p v-if="apiError" style="color: red;">错误: {{ apiError }}</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h2>项目列表</h2>
|
||||
<div v-if="loading">加载中...</div>
|
||||
<div v-else-if="projects.length > 0">
|
||||
<div v-for="project in projects" :key="project.id" style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
|
||||
<h3>{{ project.name }}</h3>
|
||||
<p>ID: {{ project.id }}</p>
|
||||
<p>创建时间: {{ project.created_at }}</p>
|
||||
<div class="test-page">
|
||||
<header>
|
||||
<h1>扑克牌设计管理系统 - 测试页面</h1>
|
||||
<p>如果看到这个页面,Vue 工作正常。</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>系统健康检查</h2>
|
||||
<div class="row">
|
||||
<button @click="checkBackend">连接后端</button>
|
||||
<button @click="checkProject">获取项目列表</button>
|
||||
<button @click="checkCardLayout">测试牌面布局</button>
|
||||
<button @click="testRenderer">测试 Canvas 渲染</button>
|
||||
</div>
|
||||
<pre v-if="apiResult" class="result">{{ apiResult }}</pre>
|
||||
<p v-if="apiError" class="error">错误: {{ apiError }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="rendered">
|
||||
<h2>Canvas 渲染测试</h2>
|
||||
<p>所有 4 个花色的 A 牌 + J + 大小王 + 背面</p>
|
||||
<div class="card-row">
|
||||
<div v-for="ck in testKeys" :key="ck" class="card-cell">
|
||||
<canvas :ref="el => setRef(ck, el)" width="120" height="168"></canvas>
|
||||
<div class="label">{{ ck }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>没有项目</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { LAYOUT_POSITIONS, SUITS, SUIT_TEXT } from '@/utils/cardLayout'
|
||||
import { renderCard } from '@/utils/cardRenderer'
|
||||
import { DEFAULT_DESIGN } from '@/utils/cardLayout'
|
||||
|
||||
const projects = ref([])
|
||||
const loading = ref(false)
|
||||
const apiResult = ref('')
|
||||
const apiError = ref('')
|
||||
const rendered = ref(false)
|
||||
const canvasRefs = ref({})
|
||||
const testKeys = [
|
||||
'spade-A', 'heart-A', 'club-A', 'diamond-A',
|
||||
'spade-7', 'spade-J', 'joker-big', 'joker-small', 'back',
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects()
|
||||
function setRef(key, el) {
|
||||
if (el) canvasRefs.value[key] = el
|
||||
}
|
||||
|
||||
async function checkBackend() {
|
||||
apiError.value = ''
|
||||
try {
|
||||
const r = await axios.get('/')
|
||||
apiResult.value = JSON.stringify(r.data, null, 2)
|
||||
} catch (e) {
|
||||
apiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProject() {
|
||||
apiError.value = ''
|
||||
try {
|
||||
const r = await axios.get('/api/projects/')
|
||||
apiResult.value = JSON.stringify(r.data, null, 2)
|
||||
} catch (e) {
|
||||
apiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function checkCardLayout() {
|
||||
apiError.value = ''
|
||||
const out = {}
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
out[i] = LAYOUT_POSITIONS[i]
|
||||
}
|
||||
apiResult.value = JSON.stringify(out, null, 2)
|
||||
}
|
||||
|
||||
async function testRenderer() {
|
||||
apiError.value = ''
|
||||
try {
|
||||
let projects = []
|
||||
try {
|
||||
const r = await axios.get('/api/projects/')
|
||||
projects = r.data
|
||||
} catch (e) { /* 离线模式 */ }
|
||||
if (!projects.length) {
|
||||
// 用默认 design 做演示
|
||||
projects = [{ id: 'demo', name: 'demo', design: DEFAULT_DESIGN, card_overrides: {}, number_layout: {}, assets: [] }]
|
||||
}
|
||||
const proj = projects[0]
|
||||
await nextTick()
|
||||
for (const ck of testKeys) {
|
||||
const cv = canvasRefs.value[ck]
|
||||
if (cv) await renderCard(cv, proj, ck)
|
||||
}
|
||||
rendered.value = true
|
||||
apiResult.value = '已渲染 ' + testKeys.length + ' 张牌'
|
||||
} catch (e) {
|
||||
apiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 自动跑一次基础检查
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/projects/')
|
||||
projects.value = response.data.value || response.data
|
||||
apiResult.value = JSON.stringify(response.data, null, 2)
|
||||
} catch (error) {
|
||||
apiError.value = error.message
|
||||
console.error('Failed to load projects:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testAPI() {
|
||||
try {
|
||||
const response = await axios.get('/')
|
||||
apiResult.value = JSON.stringify(response.data, null, 2)
|
||||
apiError.value = ''
|
||||
} catch (error) {
|
||||
apiError.value = error.message
|
||||
apiResult.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-page { padding: 20px; max-width: 1200px; margin: 0 auto; font-family: 'Microsoft YaHei', sans-serif; }
|
||||
header h1 { color: #e94560; margin: 0; }
|
||||
section { margin: 24px 0; padding: 16px; background: #f5f5f5; border-radius: 8px; }
|
||||
section h2 { margin: 0 0 12px 0; font-size: 16px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button { background: #e94560; color: white; border: none; border-radius: 4px; padding: 8px 14px; cursor: pointer; }
|
||||
button:hover { background: #ff5577; }
|
||||
.result { background: #fff; padding: 12px; border-radius: 4px; overflow-x: auto; font-size: 12px; max-height: 400px; overflow-y: auto; }
|
||||
.error { color: #e94560; }
|
||||
.card-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.card-cell { background: white; padding: 4px; border-radius: 4px; }
|
||||
.card-cell canvas { display: block; }
|
||||
.card-cell .label { text-align: center; font-size: 10px; margin-top: 2px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user