| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696 |
- <template>
- <div class="template-design-container">
- <!-- 左侧工具栏 -->
- <div class="toolbar">
- <!-- 标签页切换 -->
- <div class="toolbar-tabs">
- <el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
- <el-tab-pane label="模版设计" name="design">
- <el-upload
- class="custom-upload"
- drag
- :show-file-list="false"
- :before-upload="beforeUpload"
- :http-request="handleUpload"
- accept="image/jpeg,image/png,image/jpg,image/webp"
- multiple
- >
- <div class="upload-main">
- <div class="upload-inner-button">
- <el-icon class="custom-upload-icon">
- <Upload />
- </el-icon>
- <span class="upload-inner-text">上传素材图</span>
- </div>
- </div>
- <div class="el-upload__tip">
- 拖拽或点击上传JPEG/JPG/PNG 10M以内
- </div>
- </el-upload>
-
- <el-button type="success" :icon="Plus" style="width: 100%;" @click="addTextLayer">
- 添加文字
- </el-button>
-
- <el-divider />
-
- <!-- 画布尺寸设置 -->
- <div class="canvas-settings">
- <h4>画布尺寸</h4>
- <div class="property-item">
- <span>比例:</span>
- <el-select v-model="canvasRatio" size="small" style="flex: 1;" @change="handleCanvasRatioChange">
- <el-option label="自定义" value="custom" />
- <el-option label="1:1 (正方形)" value="1:1" />
- <el-option label="4:3 (横屏)" value="4:3" />
- <el-option label="3:4 (竖屏)" value="3:4" />
- <el-option label="16:9 (宽屏)" value="16:9" />
- <el-option label="9:16 (手机)" value="9:16" />
- <el-option label="3:2 (照片)" value="3:2" />
- <el-option label="2:3 (竖照片)" value="2:3" />
- </el-select>
- </div>
- <div class="property-item">
- <span>宽度:</span>
- <el-input-number v-model="canvasWidth" size="small" :step="10" :min="100" :max="2000" @change="handleCanvasSizeChange" />
- </div>
- <div class="property-item">
- <span>高度:</span>
- <el-input-number v-model="canvasHeight" size="small" :step="10" :min="100" :max="2000" @change="handleCanvasSizeChange" />
- </div>
- </div>
-
- <el-divider />
-
- <div class="layer-info" v-if="selectedLayer">
- <h4>图层属性</h4>
- <div class="property-item">
- <span>X:</span>
- <el-input-number v-model="selectedLayer.x" size="small" :step="1" />
- </div>
- <div class="property-item">
- <span>Y:</span>
- <el-input-number v-model="selectedLayer.y" size="small" :step="1" />
- </div>
- <div class="property-item">
- <span>宽度:</span>
- <el-input-number
- v-model="selectedLayer.width"
- size="small"
- :step="1"
- @change="handleWidthChange"
- />
- </div>
- <div class="property-item">
- <span>高度:</span>
- <el-input-number
- v-model="selectedLayer.height"
- size="small"
- :step="1"
- @change="handleHeightChange"
- />
- </div>
- <div class="property-item">
- <span>旋转:</span>
- <el-input-number v-model="selectedLayer.rotation" size="small" :step="1" :min="-360" :max="360" />
- </div>
- <div class="property-item">
- <span>透明度:</span>
- <el-slider v-model="selectedLayer.opacity" :min="0" :max="100" />
- </div>
-
- <!-- 文字图层特有属性 -->
- <template v-if="selectedLayer.type === 'text'">
- <el-divider />
- <h4>文字属性</h4>
- <div class="property-item">
- <span>内容:</span>
- <el-input
- v-model="selectedLayer.text"
- size="small"
- style="flex: 1;"
- />
- </div>
- <div class="property-item">
- <span>字体:</span>
- <el-select v-model="selectedLayer.fontFamily" size="small" style="flex: 1;">
- <el-option label="Arial" value="Arial" />
- <el-option label="宋体" value="SimSun" />
- <el-option label="黑体" value="SimHei" />
- <el-option label="微软雅黑" value="Microsoft YaHei" />
- <el-option label="楷体" value="KaiTi" />
- <el-option label="Times New Roman" value="Times New Roman" />
- </el-select>
- </div>
- <div class="property-item">
- <span>字号:</span>
- <el-input-number v-model="selectedLayer.fontSize" size="small" :min="8" :max="200" :step="1" />
- </div>
- <div class="property-item">
- <span>颜色:</span>
- <el-color-picker v-model="selectedLayer.color" size="small" />
- </div>
- <div class="property-item">
- <span>背景:</span>
- <el-color-picker v-model="selectedLayer.backgroundColor" size="small" show-alpha />
- </div>
- <div class="property-item">
- <span>对齐:</span>
- <el-radio-group v-model="selectedLayer.textAlign" size="small">
- <el-radio-button label="left">左</el-radio-button>
- <el-radio-button label="center">中</el-radio-button>
- <el-radio-button label="right">右</el-radio-button>
- </el-radio-group>
- </div>
- <div class="property-item">
- <span>加粗:</span>
- <el-switch v-model="selectedLayer.fontWeight" active-value="bold" inactive-value="normal" />
- </div>
- <div class="property-item">
- <span>斜体:</span>
- <el-switch v-model="selectedLayer.fontStyle" active-value="italic" inactive-value="normal" />
- </div>
- <div class="property-item">
- <span>下划线:</span>
- <el-switch v-model="selectedLayer.textDecoration" active-value="underline" inactive-value="none" />
- </div>
- <div class="property-item">
- <span>行高:</span>
- <el-slider v-model="selectedLayer.lineHeight" :min="0.5" :max="3" :step="0.1" />
- </div>
- <div class="property-item">
- <span>字距:</span>
- <el-slider v-model="selectedLayer.letterSpacing" :min="-5" :max="20" :step="1" />
- </div>
- </template>
- </div>
-
- <!-- 保存模版按钮 -->
- <el-divider />
- </el-tab-pane>
-
- <el-tab-pane label="素材选择" name="material" :label-class="'material-tab'">
-
- <div class="materials-list-full">
- <el-skeleton v-if="materialsLoading" :rows="5" animated />
- <div v-else-if="materials.length === 0" class="empty-materials">
- 暂无素材
- </div>
- <div
- v-else
- class="material-item-full"
- v-for="material in materials"
- :key="material.id"
- @click="addMaterialToCanvas(material)"
- >
- <img :src="material.material_url" :alt="material.id" />
- <div class="material-overlay">
- <el-icon><Plus /></el-icon>
- <span>添加</span>
- </div>
- </div>
- </div>
- </el-tab-pane>
- </el-tabs>
- </div>
- <!-- 固定在底部的保存模版按钮 -->
- <el-divider />
- <el-button
- type="primary"
- :icon="Document"
- class="save-template-btn"
- @click="saveTemplate"
- >
- 保存模版
- </el-button>
- </div>
-
- <!-- 中间画布区域 -->
- <div class="canvas-area" ref="canvasAreaRef">
- <div class="canvas-wrapper">
- <div
- ref="canvasRef"
- class="canvas"
- :style="{
- width: canvasWidth + 'px',
- height: canvasHeight + 'px'
- }"
- @mousedown="handleCanvasMouseDown"
- @mousemove="handleCanvasMouseMove"
- @mouseup="handleCanvasMouseUp"
- @mouseleave="handleCanvasMouseUp"
- @wheel="handleCanvasWheel"
- >
- <div
- v-for="(layer, index) in layers"
- :key="layer.id"
- class="layer"
- :class="{
- 'selected': selectedLayerId === layer.id,
- 'text-layer': layer.type === 'text'
- }"
- :style="getLayerStyle(layer)"
- @mousedown.stop="handleLayerMouseDown($event, layer)"
- @dblclick.stop="handleLayerDblClick($event, layer)"
- >
- <!-- 图片图层 -->
- <template v-if="layer.type !== 'text'">
- <img :src="layer.url" :alt="layer.name" draggable="false" />
- </template>
-
- <!-- 文字图层 -->
- <template v-else>
- <div
- class="text-content"
- :style="getTextStyle(layer)"
- contenteditable="false"
- >
- {{ layer.text }}
- </div>
- </template>
-
- <!-- 选中状态的调整手柄 -->
- <template v-if="selectedLayerId === layer.id">
- <div class="resize-handle nw" @mousedown.stop="startResize($event, 'nw')"></div>
- <div class="resize-handle ne" @mousedown.stop="startResize($event, 'ne')"></div>
- <div class="resize-handle sw" @mousedown.stop="startResize($event, 'sw')"></div>
- <div class="resize-handle se" @mousedown.stop="startResize($event, 'se')"></div>
- <div class="rotate-handle" @mousedown.stop="startRotate($event)"></div>
- </template>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 右侧图层面板 -->
- <div class="layer-panel">
- <h3>图层</h3>
- <div class="layer-actions">
- <el-button size="small" @click="moveLayerUp" :disabled="!canMoveUp">
- <el-icon><ArrowUp /></el-icon>
- </el-button>
- <el-button size="small" @click="moveLayerDown" :disabled="!canMoveDown">
- <el-icon><ArrowDown /></el-icon>
- </el-button>
- <el-button size="small" type="danger" @click="deleteLayer" :disabled="!selectedLayerId">
- <el-icon><Delete /></el-icon>
- </el-button>
- </div>
-
- <el-divider />
-
- <div class="layer-list" ref="layerListRef">
- <div
- v-for="(layer, index) in reversedLayers"
- :key="layer.id"
- class="layer-item"
- :class="{
- 'selected': selectedLayerId === layer.id,
- 'dragging': dragState.draggingId === layer.id,
- 'drag-over': dragState.dragOverId === layer.id,
- 'locked': layer.locked
- }"
- :draggable="true"
- @click="selectLayer(layer.id)"
- @dragstart="handleDragStart($event, layer)"
- @dragover="handleDragOver($event, layer)"
- @drop="handleDrop($event, layer)"
- @dragend="handleDragEnd"
- @dragenter="handleDragEnter($event, layer)"
- @dragleave="handleDragLeave"
- >
- <el-icon class="drag-handle">
- <Rank />
- </el-icon>
- <el-icon class="visibility-icon" @click.stop="toggleLayerVisibility(layer.id)">
- <View v-if="layer.visible" />
- <Hide v-else />
- </el-icon>
- <el-icon class="lock-icon" @click.stop="toggleLayerLock(layer.id)">
- <Lock v-if="layer.locked" />
- <Unlock v-else />
- </el-icon>
- <template v-if="layer.type === 'text'">
- <div class="layer-thumbnail text-thumbnail">
- <el-icon><Document /></el-icon>
- </div>
- </template>
- <template v-else>
- <img :src="layer.url" class="layer-thumbnail" />
- </template>
- <span class="layer-name">{{ layer.name }}</span>
- </div>
-
- <div v-if="layers.length === 0" class="empty-tip">
- 暂无图层,请上传素材
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
- import { ElMessage } from 'element-plus'
- import { Pointer, Rank, ArrowUp, ArrowDown, Delete, View, Hide, Lock, Unlock, Plus, Document, Picture, Refresh, Upload } from '@element-plus/icons-vue'
- import { Material_List } from '@/api/mes/job'
- const canvasRef = ref(null)
- const canvasAreaRef = ref(null)
- const layerListRef = ref(null)
- const currentTool = ref('select')
- const activeTab = ref('design') // 'design' 或 'material'
- const canvasWidth = ref(600)
- const canvasHeight = ref(450)
- const canvasRatio = ref('4:3')
- const zoomLevel = ref(100)
- const layers = ref([])
- const selectedLayerId = ref(null)
- const maintainAspectRatio = ref(true) // 默认启用等比例缩放
- // 拖拽状态
- const dragState = reactive({
- draggingId: null,
- dragOverId: null,
- draggedLayer: null
- })
- // 画布比例配置 - 使用更小的默认尺寸以适应屏幕
- const ratioConfig = {
- '1:1': { width: 500, height: 500, ratio: 1 },
- '4:3': { width: 600, height: 450, ratio: 4/3 },
- '3:4': { width: 450, height: 600, ratio: 3/4 },
- '16:9': { width: 640, height: 360, ratio: 16/9 },
- '9:16': { width: 360, height: 640, ratio: 9/16 },
- '3:2': { width: 600, height: 400, ratio: 3/2 },
- '2:3': { width: 400, height: 600, ratio: 2/3 }
- }
- const selectedLayer = computed(() => {
- return layers.value.find(l => l.id === selectedLayerId.value) || null
- })
- const reversedLayers = computed(() => {
- return [...layers.value].reverse()
- })
- const selectedLayerIndex = computed(() => {
- return layers.value.findIndex(l => l.id === selectedLayerId.value)
- })
- const canMoveUp = computed(() => {
- return selectedLayerId.value && selectedLayerIndex.value < layers.value.length - 1
- })
- const canMoveDown = computed(() => {
- return selectedLayerId.value && selectedLayerIndex.value > 0
- })
- let layerIdCounter = 0
- const beforeUpload = (file) => {
- const isImage = file.type.startsWith('image/')
- const isLt10M = file.size / 1024 / 1024 < 10
-
- if (!isImage) {
- ElMessage.error('只能上传图片文件!')
- return false
- }
- if (!isLt10M) {
- ElMessage.error('图片大小不能超过 10MB!')
- return false
- }
- return true
- }
- const handleUpload = (options) => {
- const { file } = options
- const reader = new FileReader()
-
- reader.onload = (e) => {
- const img = new Image()
- img.onload = () => {
- const maxSize = 300
- let width = img.width
- let height = img.height
-
- if (width > maxSize || height > maxSize) {
- const ratio = Math.min(maxSize / width, maxSize / height)
- width = width * ratio
- height = height * ratio
- }
-
- const newLayer = {
- id: ++layerIdCounter,
- name: file.name,
- url: e.target.result,
- x: Math.round((canvasWidth.value - width) / 2),
- y: Math.round((canvasHeight.value - height) / 2),
- width: Math.round(width),
- height: Math.round(height),
- rotation: 0,
- opacity: 100,
- visible: true,
- locked: false,
- originalWidth: img.width,
- originalHeight: img.height
- }
-
- layers.value.push(newLayer)
- selectedLayerId.value = newLayer.id
- ElMessage.success('素材上传成功!')
- }
- img.src = e.target.result
- }
-
- reader.readAsDataURL(file)
- }
- // 添加文字图层
- const addTextLayer = () => {
- const newLayer = {
- id: ++layerIdCounter,
- name: '文字 ' + (textLayerCount.value + 1),
- type: 'text',
- text: '双击编辑文字',
- x: canvasWidth.value / 2 - 50,
- y: canvasHeight.value / 2 - 15,
- width: 100,
- height: 30,
- rotation: 0,
- opacity: 100,
- visible: true,
- locked: false,
- fontSize: 16,
- fontFamily: 'Arial',
- fontWeight: 'normal',
- fontStyle: 'normal',
- textDecoration: 'none',
- color: '#000000',
- textAlign: 'left',
- backgroundColor: 'transparent',
- lineHeight: 1.5,
- letterSpacing: 0
- }
-
- layers.value.push(newLayer)
- selectedLayerId.value = newLayer.id
- textLayerCount.value++
- ElMessage.success('文字图层已添加')
- }
- const textLayerCount = ref(0)
- // 素材库状态
- const materials = ref([])
- const materialsLoading = ref(false)
- // 获取素材库数据
- const fetchMaterials = async () => {
- console.log('fetchMaterials called')
- try {
- materialsLoading.value = true
- console.log('开始获取素材库数据')
- const response = await Material_List()
- console.log('素材库数据:', response)
- if (response.code === 0) {
- materials.value = response.data
- console.log('素材库数据更新成功:', materials.value)
- } else {
- console.error('获取素材库失败:', response)
- ElMessage.error('获取素材库失败')
- }
- } catch (error) {
- console.error('获取素材库错误:', error)
- ElMessage.error('获取素材库失败')
- } finally {
- materialsLoading.value = false
- console.log('获取素材库完成')
- }
- }
- // 标签页点击事件
- const handleTabClick = (tab) => {
- console.log('Tab clicked:', tab.props.name)
- if (tab.props.name === 'material') {
- fetchMaterials()
- }
- }
- // 点击素材添加到画布
- const addMaterialToCanvas = (material) => {
- const img = new Image()
- img.onload = function() {
- let width = img.width
- let height = img.height
-
- const maxSize = 200
- if (width > maxSize || height > maxSize) {
- const ratio = Math.min(maxSize / width, maxSize / height)
- width = width * ratio
- height = height * ratio
- }
-
- const newLayer = {
- id: ++layerIdCounter,
- name: `素材 ${layerIdCounter}`,
- url: material.material_url,
- x: Math.round((canvasWidth.value - width) / 2),
- y: Math.round((canvasHeight.value - height) / 2),
- width: Math.round(width),
- height: Math.round(height),
- rotation: 0,
- opacity: 100,
- visible: true,
- locked: false,
- originalWidth: img.width,
- originalHeight: img.height
- }
-
- layers.value.push(newLayer)
- selectedLayerId.value = newLayer.id
- ElMessage.success('素材已添加到画布!')
- }
- img.src = material.material_url
- }
- // 组件挂载时获取素材库数据
- // 全局点击事件处理函数:仅在中间画布区域内点击空白处时取消选中
- const handleGlobalClick = (e) => {
- const areaEl = canvasAreaRef.value
- if (!areaEl) return
- // 是否在中间画布区域内点击
- const isInCanvasArea = areaEl.contains(e.target)
- // 检查是否点击了图层
- const isLayerClick = e.target.closest('.layer')
- // 检查是否点击了画布
- const isCanvasClick = e.target === canvasRef.value || canvasRef.value?.contains(e.target)
- // 只有在中间画布区域内,并且既没有点击图层也没有点击画布,才取消选择
- if (isInCanvasArea && !isLayerClick && !isCanvasClick) {
- selectedLayerId.value = null
- }
- }
- onMounted(() => {
- fetchMaterials()
- // 添加全局点击事件监听器
- document.addEventListener('click', handleGlobalClick)
- })
- onUnmounted(() => {
- // 移除全局点击事件监听器
- document.removeEventListener('click', handleGlobalClick)
- })
- // 保存模版
- const saveTemplate = () => {
- // 构建模版数据
- const templateData = {
- canvasWidth: canvasWidth.value,
- canvasHeight: canvasHeight.value,
- canvasRatio: canvasRatio.value,
- layers: layers.value.map(layer => ({
- id: layer.id,
- name: layer.name,
- type: layer.type || 'image',
- url: layer.url,
- text: layer.text,
- x: layer.x,
- y: layer.y,
- width: layer.width,
- height: layer.height,
- rotation: layer.rotation,
- opacity: layer.opacity,
- visible: layer.visible,
- locked: layer.locked,
- fontFamily: layer.fontFamily,
- fontSize: layer.fontSize,
- color: layer.color,
- backgroundColor: layer.backgroundColor,
- fontWeight: layer.fontWeight,
- fontStyle: layer.fontStyle,
- textDecoration: layer.textDecoration,
- lineHeight: layer.lineHeight,
- letterSpacing: layer.letterSpacing,
- textAlign: layer.textAlign,
- originalWidth: layer.originalWidth,
- originalHeight: layer.originalHeight
- }))
- }
-
- // 这里可以根据实际需求发送请求到后端保存模版
- // 暂时使用本地存储示例
- console.log('保存模版数据:', templateData)
-
- // 示例: 保存到本地存储
- localStorage.setItem('templateDesign', JSON.stringify(templateData))
-
- ElMessage.success('模版保存成功!')
- }
- const getLayerStyle = (layer) => {
- if (!layer.visible) {
- return { display: 'none' }
- }
-
- return {
- left: layer.x + 'px',
- top: layer.y + 'px',
- width: layer.type === 'text' ? 'auto' : layer.width + 'px',
- height: layer.type === 'text' ? 'auto' : layer.height + 'px',
- transform: `rotate(${layer.rotation}deg)`,
- opacity: layer.opacity / 100
- }
- }
- // 获取文字图层样式
- const getTextStyle = (layer) => {
- return {
- fontSize: layer.fontSize + 'px',
- fontFamily: layer.fontFamily,
- fontWeight: layer.fontWeight,
- fontStyle: layer.fontStyle,
- textDecoration: layer.textDecoration,
- color: layer.color,
- textAlign: layer.textAlign,
- backgroundColor: layer.backgroundColor,
- lineHeight: layer.lineHeight,
- letterSpacing: layer.letterSpacing + 'px',
- padding: '4px 8px',
- whiteSpace: 'nowrap',
- userSelect: 'none'
- }
- }
- // 双击编辑文字
- const editingTextLayer = ref(null)
- const handleLayerDblClick = (e, layer) => {
- if (layer.type === 'text' && !layer.locked) {
- editingTextLayer.value = layer
- // 创建输入框进行编辑
- const input = document.createElement('input')
- input.value = layer.text
- input.style.cssText = `
- position: absolute;
- left: ${layer.x}px;
- top: ${layer.y}px;
- font-size: ${layer.fontSize}px;
- font-family: ${layer.fontFamily};
- font-weight: ${layer.fontWeight};
- font-style: ${layer.fontStyle};
- color: ${layer.color};
- background: white;
- border: 2px solid #409eff;
- padding: 4px 8px;
- z-index: 1000;
- outline: none;
- `
-
- const canvas = canvasRef.value
- canvas.appendChild(input)
- input.focus()
- input.select()
-
- const saveEdit = () => {
- layer.text = input.value || '双击编辑文字'
- canvas.removeChild(input)
- editingTextLayer.value = null
- }
-
- input.addEventListener('blur', saveEdit)
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- saveEdit()
- }
- })
- }
- }
- // 画布比例变化处理
- const handleCanvasRatioChange = (ratio) => {
- if (ratio === 'custom') return
-
- const config = ratioConfig[ratio]
- if (config) {
- canvasWidth.value = config.width
- canvasHeight.value = config.height
- ElMessage.success(`画布尺寸已设置为 ${ratio}`)
- }
- }
- // 画布尺寸变化处理
- const handleCanvasSizeChange = () => {
- // 当手动调整尺寸时,重置比例为自定义
- if (canvasRatio.value !== 'custom') {
- const config = ratioConfig[canvasRatio.value]
- if (config) {
- const currentRatio = canvasWidth.value / canvasHeight.value
- const expectedRatio = config.ratio
- // 如果比例偏差较大,切换到自定义
- if (Math.abs(currentRatio - expectedRatio) > 0.1) {
- canvasRatio.value = 'custom'
- }
- }
- }
- }
- // 图片宽度变化时,如果锁定比例,自动调整高度
- const handleWidthChange = (newWidth) => {
- if (!maintainAspectRatio.value || !selectedLayer.value) return
-
- const layer = selectedLayer.value
- const aspectRatio = layer.originalWidth / layer.originalHeight
- layer.height = Math.round(newWidth / aspectRatio)
- }
- // 图片高度变化时,如果锁定比例,自动调整宽度
- const handleHeightChange = (newHeight) => {
- if (!maintainAspectRatio.value || !selectedLayer.value) return
-
- const layer = selectedLayer.value
- const aspectRatio = layer.originalWidth / layer.originalHeight
- layer.width = Math.round(newHeight * aspectRatio)
- }
- const selectLayer = (id) => {
- selectedLayerId.value = id
- }
- const toggleLayerVisibility = (id) => {
- const layer = layers.value.find(l => l.id === id)
- if (layer) {
- layer.visible = !layer.visible
- }
- }
- const toggleLayerLock = (id) => {
- const layer = layers.value.find(l => l.id === id)
- if (layer) {
- layer.locked = !layer.locked
- ElMessage.success(layer.locked ? '图层已锁定' : '图层已解锁')
- }
- }
- const moveLayerUp = () => {
- const index = selectedLayerIndex.value
- if (index < layers.value.length - 1) {
- const temp = layers.value[index]
- layers.value[index] = layers.value[index + 1]
- layers.value[index + 1] = temp
- }
- }
- const moveLayerDown = () => {
- const index = selectedLayerIndex.value
- if (index > 0) {
- const temp = layers.value[index]
- layers.value[index] = layers.value[index - 1]
- layers.value[index - 1] = temp
- }
- }
- // 拖拽排序相关函数
- const handleDragStart = (e, layer) => {
- // 如果图层被锁定,禁止拖拽排序
- if (layer.locked) {
- e.preventDefault()
- ElMessage.warning('图层已锁定,无法排序')
- return
- }
-
- dragState.draggingId = layer.id
- dragState.draggedLayer = layer
- e.dataTransfer.effectAllowed = 'move'
- e.dataTransfer.setData('text/plain', layer.id)
- // 设置拖拽时的半透明效果
- e.target.style.opacity = '0.5'
- }
- const handleDragOver = (e, layer) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
- const handleDragEnter = (e, layer) => {
- e.preventDefault()
- if (dragState.draggingId !== layer.id) {
- dragState.dragOverId = layer.id
- }
- }
- const handleDragLeave = (e) => {
- // 只有当离开当前元素时才清除dragOverId
- if (!e.currentTarget.contains(e.relatedTarget)) {
- dragState.dragOverId = null
- }
- }
- const handleDrop = (e, targetLayer) => {
- e.preventDefault()
- e.stopPropagation()
-
- const draggedId = dragState.draggingId
- const targetId = targetLayer.id
-
- if (draggedId && draggedId !== targetId) {
- // 获取原始索引(在layers数组中的索引,不是reversedLayers)
- const draggedIndex = layers.value.findIndex(l => l.id === draggedId)
- const targetIndex = layers.value.findIndex(l => l.id === targetId)
-
- if (draggedIndex !== -1 && targetIndex !== -1) {
- // 移动图层
- const [movedLayer] = layers.value.splice(draggedIndex, 1)
- layers.value.splice(targetIndex, 0, movedLayer)
-
- ElMessage.success('图层排序已更新')
- }
- }
-
- // 重置拖拽状态
- dragState.dragOverId = null
- }
- const handleDragEnd = (e) => {
- // 恢复透明度
- if (e.target) {
- e.target.style.opacity = '1'
- }
- // 重置所有拖拽状态
- dragState.draggingId = null
- dragState.dragOverId = null
- dragState.draggedLayer = null
- }
- const deleteLayer = () => {
- const index = selectedLayerIndex.value
- if (index !== -1) {
- layers.value.splice(index, 1)
- selectedLayerId.value = layers.value.length > 0 ? layers.value[0].id : null
- ElMessage.success('图层已删除')
- }
- }
- let isDragging = false
- let isResizing = false
- let isRotating = false
- let dragStartX = 0
- let dragStartY = 0
- let layerStartX = 0
- let layerStartY = 0
- let resizeDirection = ''
- let startWidth = 0
- let startHeight = 0
- let startRotation = 0
- let centerX = 0
- let centerY = 0
- const handleLayerMouseDown = (e, layer) => {
- if (currentTool.value !== 'select' && currentTool.value !== 'move') return
-
- // 如果图层被锁定,禁止拖拽
- if (layer.locked) {
- selectedLayerId.value = layer.id
- ElMessage.warning('图层已锁定,无法移动')
- return
- }
-
- selectedLayerId.value = layer.id
-
- isDragging = true
- dragStartX = e.clientX
- dragStartY = e.clientY
- layerStartX = layer.x
- layerStartY = layer.y
-
- e.preventDefault()
- }
- const handleCanvasMouseDown = (e) => {
- // 确保 canvasRef 已经初始化
- if (canvasRef.value && e.target === canvasRef.value) {
- selectedLayerId.value = null
- }
- }
- const handleCanvasMouseMove = (e) => {
- if (!selectedLayer.value) return
-
- if (isDragging) {
- const deltaX = e.clientX - dragStartX
- const deltaY = e.clientY - dragStartY
-
- selectedLayer.value.x = Math.round(layerStartX + deltaX)
- selectedLayer.value.y = Math.round(layerStartY + deltaY)
- }
-
- if (isResizing) {
- const deltaX = e.clientX - dragStartX
- const deltaY = e.clientY - dragStartY
-
- const layer = selectedLayer.value
- const aspectRatio = layer.originalWidth / layer.originalHeight
-
- let newWidth = startWidth
- let newHeight = startHeight
-
- // 计算新的宽度和高度
- if (resizeDirection.includes('e')) {
- newWidth = Math.max(20, startWidth + deltaX)
- }
- if (resizeDirection.includes('w')) {
- newWidth = Math.max(20, startWidth - deltaX)
- }
- if (resizeDirection.includes('s')) {
- newHeight = Math.max(20, startHeight + deltaY)
- }
- if (resizeDirection.includes('n')) {
- newHeight = Math.max(20, startHeight - deltaY)
- }
-
- // 如果锁定等比例,根据宽高比调整
- if (maintainAspectRatio.value) {
- if (resizeDirection === 'se' || resizeDirection === 'nw') {
- // 对角线调整,以宽度为准
- newHeight = newWidth / aspectRatio
- } else if (resizeDirection === 'ne' || resizeDirection === 'sw') {
- // 对角线调整,以宽度为准
- newHeight = newWidth / aspectRatio
- } else if (resizeDirection.includes('e') || resizeDirection.includes('w')) {
- // 水平调整
- newHeight = newWidth / aspectRatio
- } else if (resizeDirection.includes('n') || resizeDirection.includes('s')) {
- // 垂直调整
- newWidth = newHeight * aspectRatio
- }
- }
-
- // 应用新的尺寸和位置
- if (resizeDirection.includes('w')) {
- layer.x = Math.round(layerStartX + (startWidth - newWidth))
- }
- if (resizeDirection.includes('n')) {
- layer.y = Math.round(layerStartY + (startHeight - newHeight))
- }
-
- layer.width = Math.round(newWidth)
- layer.height = Math.round(newHeight)
- }
-
- if (isRotating) {
- const rect = canvasRef.value.getBoundingClientRect()
- const mouseX = e.clientX - rect.left
- const mouseY = e.clientY - rect.top
-
- const angle = Math.atan2(mouseY - centerY, mouseX - centerX) * 180 / Math.PI
- selectedLayer.value.rotation = Math.round(angle + 90)
- }
- }
- const handleCanvasMouseUp = () => {
- isDragging = false
- isResizing = false
- isRotating = false
- }
- const startResize = (e, direction) => {
- if (!selectedLayer.value) return
-
- isResizing = true
- resizeDirection = direction
- dragStartX = e.clientX
- dragStartY = e.clientY
- startWidth = selectedLayer.value.width
- startHeight = selectedLayer.value.height
- layerStartX = selectedLayer.value.x
- layerStartY = selectedLayer.value.y
- }
- const startRotate = (e) => {
- if (!selectedLayer.value) return
-
- isRotating = true
-
- const rect = canvasRef.value.getBoundingClientRect()
- centerX = selectedLayer.value.x + selectedLayer.value.width / 2
- centerY = selectedLayer.value.y + selectedLayer.value.height / 2
- startRotation = selectedLayer.value.rotation
- }
- const handleCanvasWheel = (e) => {
- e.preventDefault()
- const delta = e.deltaY > 0 ? -10 : 10
- zoomLevel.value = Math.max(10, Math.min(200, zoomLevel.value + delta))
- }
- </script>
- <style scoped>
- .template-design-container {
- display: flex;
- height: calc(100vh - 140px);
- background-color: #f5f5f5;
- }
- .toolbar {
- width: 300px;
- background-color: #fff;
- border-right: 1px solid #ddd;
- padding: 8px 12px;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
- overflow: hidden; /* 外层高度锁死,内部区域单独滚动 */
- }
- .toolbar h3 {
- margin: 0 0 8px 0;
- font-size: 14px;
- color: #333;
- flex-shrink: 0;
- }
- .toolbar-tabs {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- .toolbar-tabs :deep(.el-tabs__header) {
- flex-shrink: 0;
- margin-bottom: 8px;
- }
- .toolbar-tabs :deep(.el-tabs__content) {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: 0;
- }
- .toolbar-tabs :deep(.el-tab-pane) {
- height: 100%;
- display: flex;
- flex-direction: column;
- }
- .toolbar-tabs :deep(.material-tab) {
- color: red !important;
- font-weight: bold;
- }
- /* 完整素材库样式 */
- .materials-library-full {
- flex: 1;
- display: flex;
- flex-direction: column;
- height: 100%;
- }
- .materials-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
- }
- .materials-header h4 {
- margin: 0;
- font-size: 12px;
- color: #666;
- display: flex;
- align-items: center;
- gap: 4px;
- }
- .materials-list-full {
- flex: 1;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 6px;
- overflow-y: auto;
- padding: 4px;
- background-color: #f9f9f9;
- border-radius: 4px;
- min-height: 0;
- }
- .material-item-full {
- position: relative;
- aspect-ratio: 1;
- cursor: pointer;
- border-radius: 8px;
- overflow: hidden;
- border: 1px solid #e5e5e5;
- transition: all 0.2s;
- }
- .material-item-full:hover {
- border-color: #409eff;
- transform: scale(1.03);
- z-index: 1;
- }
- .material-item-full img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- }
- .material-item-full .material-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.6);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transition: opacity 0.2s;
- color: white;
- gap: 4px;
- font-size: 12px;
- }
- .material-item-full:hover .material-overlay {
- opacity: 1;
- }
- .material-item-full .material-overlay .el-icon {
- font-size: 24px;
- }
- .tool-buttons {
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- }
- .canvas-settings {
- flex-shrink: 0;
- }
- .canvas-settings h4,
- .layer-info h4 {
- margin: 0 0 6px 0;
- font-size: 12px;
- color: #666;
- }
- .layer-info {
- flex: 1;
- min-height: 0;
- padding-right: 4px;
- }
- .property-item {
- margin-bottom: 4px;
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .property-item span {
- width: 40px;
- font-size: 11px;
- color: #666;
- flex-shrink: 0;
- }
- .property-item .el-input-number {
- flex: 1;
- min-width: 0;
- }
- .property-item .el-slider {
- flex: 1;
- min-width: 0;
- }
- /* 缩小表单元素 */
- :deep(.el-input-number--small) {
- width: 100% !important;
- }
- :deep(.el-input-number--small .el-input__wrapper) {
- padding: 0 4px;
- }
- :deep(.el-button--small) {
- padding: 4px 8px;
- font-size: 11px;
- }
- :deep(.el-select--small) {
- width: 100%;
- }
- :deep(.el-switch__label) {
- font-size: 11px;
- }
- /* 修复滑块样式 */
- :deep(.el-slider) {
- width: 100%;
- }
- :deep(.el-slider__runway) {
- margin: 8px 0;
- }
- :deep(.el-slider__button-wrapper) {
- z-index: 10;
- }
- .canvas-area {
- flex: 1;
- display: flex;
- flex-direction: column;
- background-color: #e8e8e8;
- overflow: hidden;
- min-width: 0;
- }
- .canvas-wrapper {
- flex: 1;
- display: flex;
- justify-content: center;
- align-items: center;
- overflow: hidden;
- padding: 8px;
- }
- .canvas {
- position: relative;
- background-color: #fff;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- overflow: hidden;
- }
- .layer {
- position: absolute;
- cursor: move;
- user-select: none;
- border: 2px solid transparent;
- transition: border-color 0.2s;
- }
- .layer.selected {
- border-color: #409eff;
- }
- .layer img {
- width: 100%;
- height: 100%;
- display: block;
- pointer-events: none;
- }
- .text-content {
- display: inline-block;
- min-width: 50px;
- cursor: text;
- }
- .text-layer {
- border: 1px dashed #ccc;
- }
- .text-layer.selected {
- border-color: #409eff;
- border-style: solid;
- }
- .resize-handle {
- position: absolute;
- width: 10px;
- height: 10px;
- background-color: #409eff;
- border: 2px solid #fff;
- border-radius: 50%;
- cursor: pointer;
- z-index: 10;
- }
- .resize-handle.nw {
- top: -5px;
- left: -5px;
- cursor: nw-resize;
- }
- .resize-handle.ne {
- top: -5px;
- right: -5px;
- cursor: ne-resize;
- }
- .resize-handle.sw {
- bottom: -5px;
- left: -5px;
- cursor: sw-resize;
- }
- .resize-handle.se {
- bottom: -5px;
- right: -5px;
- cursor: se-resize;
- }
- .rotate-handle {
- position: absolute;
- top: -30px;
- left: 50%;
- transform: translateX(-50%);
- width: 16px;
- height: 16px;
- background-color: #409eff;
- border: 2px solid #fff;
- border-radius: 50%;
- cursor: grab;
- z-index: 10;
- }
- .rotate-handle::before {
- content: '';
- position: absolute;
- top: 100%;
- left: 50%;
- transform: translateX(-50%);
- width: 1px;
- height: 14px;
- background-color: #409eff;
- }
- .layer-panel {
- width: 200px;
- background-color: #fff;
- border-left: 1px solid #ddd;
- padding: 8px 12px;
- display: flex;
- flex-direction: column;
- }
- .layer-panel h3 {
- margin: 0 0 8px 0;
- font-size: 14px;
- color: #333;
- flex-shrink: 0;
- }
- .layer-actions {
- display: flex;
- gap: 6px;
- flex-shrink: 0;
- }
- .layer-list {
- flex: 1;
- margin-top: 6px;
- min-height: 0;
- }
- .layer-item {
- display: flex;
- align-items: center;
- padding: 4px 6px;
- margin-bottom: 3px;
- background-color: #f9f9f9;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- gap: 6px;
- user-select: none;
- }
- .layer-item:hover {
- background-color: #f0f0f0;
- }
- .layer-item.selected {
- background-color: #e6f7ff;
- border: 1px solid #91d5ff;
- }
- .layer-item.dragging {
- opacity: 0.5;
- cursor: grabbing;
- }
- .layer-item.drag-over {
- background-color: #d9ecff;
- border: 2px dashed #409eff;
- transform: scale(1.02);
- }
- .drag-handle {
- font-size: 14px;
- color: #999;
- cursor: grab;
- transition: color 0.2s;
- }
- .drag-handle:hover {
- color: #409eff;
- }
- .layer-item.dragging .drag-handle {
- cursor: grabbing;
- }
- .visibility-icon {
- font-size: 14px;
- color: #666;
- cursor: pointer;
- }
- .visibility-icon:hover {
- color: #409eff;
- }
- .lock-icon {
- font-size: 14px;
- color: #666;
- cursor: pointer;
- transition: color 0.2s;
- }
- .lock-icon:hover {
- color: #f56c6c;
- }
- .layer-item.locked {
- opacity: 0.7;
- }
- .layer-item.locked .drag-handle {
- cursor: not-allowed;
- color: #ccc;
- }
- .layer-thumbnail {
- width: 32px;
- height: 32px;
- object-fit: cover;
- border-radius: 4px;
- border: 1px solid #ddd;
- }
- .text-thumbnail {
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: #f0f0f0;
- color: #666;
- font-size: 16px;
- }
- .layer-name {
- flex: 1;
- font-size: 11px;
- color: #333;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .empty-tip {
- text-align: center;
- color: #999;
- padding: 10px;
- font-size: 11px;
- }
- .el-divider {
- margin: 6px 0;
- }
- .save-template-btn {
- width: 100%;
- margin-top: 4px;
- flex-shrink: 0;
- }
- /* 隐藏滚动条但保持功能 */
- .layer-list::-webkit-scrollbar,
- .layer-info::-webkit-scrollbar {
- width: 4px;
- }
- .layer-list::-webkit-scrollbar-thumb,
- .layer-info::-webkit-scrollbar-thumb {
- background-color: #ddd;
- border-radius: 2px;
- }
- .layer-list::-webkit-scrollbar-track,
- .layer-info::-webkit-scrollbar-track {
- background-color: transparent;
- }
- /* 覆盖 admin-box 的 margin-bottom */
- :deep(.admin-box) {
- margin-bottom: 0 !important;
- }
- /* 确保容器占满整个视口 */
- .template-design-container {
- margin: 0 !important;
- padding: 0 !important;
- }
- /* 素材库样式 */
- .materials-library {
- margin: 8px 0;
- }
- .materials-library h4 {
- margin: 0 0 8px 0;
- font-size: 12px;
- color: #333;
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- .materials-list {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 6px;
- max-height: 200px;
- padding: 4px;
- background-color: #f9f9f9;
- border-radius: 4px;
- }
- .material-item {
- position: relative;
- aspect-ratio: 1;
- cursor: pointer;
- border-radius: 4px;
- overflow: hidden;
- border: 1px solid #ddd;
- transition: all 0.2s;
- }
- .material-item:hover {
- border-color: #409eff;
- transform: scale(1.05);
- z-index: 1;
- }
- .material-item img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- }
- .material-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transition: opacity 0.2s;
- color: white;
- font-size: 20px;
- }
- .material-item:hover .material-overlay {
- opacity: 1;
- }
- .empty-materials {
- grid-column: 1 / -1;
- text-align: center;
- padding: 20px;
- color: #999;
- font-size: 12px;
- }
- .materials-list::-webkit-scrollbar {
- width: 4px;
- }
- .materials-list::-webkit-scrollbar-thumb {
- background-color: #ddd;
- border-radius: 2px;
- }
- .materials-list::-webkit-scrollbar-track {
- background-color: transparent;
- }
- /* 自定义上传样式,改成卡片风格 */
- .custom-upload {
- width: 100%;
- margin-bottom: 8px;
- }
- .custom-upload :deep(.el-upload-dragger) {
- padding: 20px 16px;
- background-color: #ffffff;
- border: 1px dashed #dcdfe6;
- border-radius: 4px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 12px;
- box-sizing: border-box;
- }
- .custom-upload :deep(.upload-main) {
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .custom-upload :deep(.upload-inner-button) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 6px 16px;
- border-radius: 999px;
- border: 1px solid #dcdfe6;
- background-color: #ffffff;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
- cursor: pointer;
- gap: 4px;
- }
- .custom-upload-icon {
- font-size: 18px;
- color: #409eff;
- }
- .custom-upload :deep(.upload-inner-text) {
- font-size: 13px;
- color: #333333;
- }
- .custom-upload :deep(.el-upload__text) {
- margin: 0;
- font-size: 14px;
- color: #333333;
- }
- .custom-upload :deep(.el-upload__tip) {
- font-size: 12px !important;
- color: #888888 !important;
- text-align: center;
- line-height: 1.4;
- }
- </style>
|