liuhairui 2 settimane fa
parent
commit
c68174d0f8
2 ha cambiato i file con 1539 aggiunte e 0 eliminazioni
  1. 9 0
      src/api/mes/job.js
  2. 1530 0
      src/view/TemplateManagement/TemplateDesign.vue

+ 9 - 0
src/api/mes/job.js

@@ -1380,3 +1380,12 @@ export const GetImageStatus = (params) => {
     params
   })
 }
+
+//素材库数据
+export const Material_List = (params) => {
+  return service({
+    url: '/mes_server/Material/Material_List',
+    method: 'get',
+    params
+  })
+}

+ 1530 - 0
src/view/TemplateManagement/TemplateDesign.vue

@@ -0,0 +1,1530 @@
+<template>
+  <div class="template-design-container">
+    <!-- 左侧工具栏 -->
+    <div class="toolbar">
+      <!-- 标签页切换 -->
+      <el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
+        <el-tab-pane label="模版设计" name="design">
+          <el-upload
+            :show-file-list="false"
+            :before-upload="beforeUpload"
+            :http-request="handleUpload"
+            accept="image/*"
+            multiple
+          >
+            <el-button type="primary" :icon="Upload" style="width: 100%; margin-bottom: 8px;">
+              上传素材图
+            </el-button>
+          </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-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>
+    
+    <!-- 中间画布区域 -->
+    <div class="canvas-area">
+      <div class="canvas-wrapper">
+        <div 
+          ref="canvasRef"
+          class="canvas"
+          :style="{
+            width: canvasWidth + 'px',
+            height: canvasHeight + 'px'
+          }"
+          @mousedown="handleCanvasMouseDown"
+          @mousemove="handleCanvasMouseMove"
+          @mouseup="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 } 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 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: (canvasWidth.value - width) / 2,
+        y: (canvasHeight.value - height) / 2,
+        width: width,
+        height: 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: (canvasWidth.value - width) / 2,
+      y: (canvasHeight.value - height) / 2,
+      width: width,
+      height: 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
+}
+
+// 组件挂载时获取素材库数据
+onMounted(() => {
+  fetchMaterials()
+})
+
+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) => {
+  if (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 - 50px);
+  background-color: #f5f5f5;
+}
+
+.toolbar {
+  width: 220px;
+  background-color: #fff;
+  border-right: 1px solid #ddd;
+  padding: 8px 12px;
+  display: flex;
+  flex-direction: column;
+}
+
+.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(2, 1fr);
+  gap: 8px;
+  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: 6px;
+  overflow: hidden;
+  border: 2px solid #ddd;
+  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;
+  overflow: hidden;
+}
+
+.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;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #999;
+  padding: 10px;
+  font-size: 11px;
+}
+
+.el-divider {
+  margin: 6px 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;
+  overflow-y: auto;
+  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;
+}
+</style>