重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器

This commit is contained in:
Developer
2026-06-01 17:11:06 +08:00
parent bde508dcfe
commit 2a36aa593c
20 changed files with 2326 additions and 853 deletions

View 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>

View File

@@ -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>

View 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>

View 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
}

View File

@@ -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 保持一致。
* 实际渲染在前端用 canvasdrawCard后端用 PILgenerate_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 },
}

View 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 }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>