|
|
@@ -0,0 +1,3385 @@
|
|
|
+<template>
|
|
|
+ <div class="template-design-container">
|
|
|
+ <!-- 创建/编辑模版页:左侧工具栏 -->
|
|
|
+ <div class="toolbar">
|
|
|
+ <div class="design-toolbar">
|
|
|
+ <button type="button" class="back-to-records" @click="$emit('back')">
|
|
|
+ <el-icon><ArrowLeft /></el-icon>
|
|
|
+ <span>返回</span>
|
|
|
+ </button>
|
|
|
+ <div class="design-toolbar-name-wrap">
|
|
|
+ <div
|
|
|
+ ref="templateNameElRef"
|
|
|
+ contenteditable="true"
|
|
|
+ class="design-toolbar-name-edit"
|
|
|
+ :data-placeholder="templateName ? '' : '模版名称'"
|
|
|
+ @blur="syncTemplateNameFromEl"
|
|
|
+ @input="enforceTemplateNameMaxLength"
|
|
|
+ ></div>
|
|
|
+ <el-icon class="design-toolbar-name-icon" @click="focusTemplateNameEl"><EditPen /></el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 左侧竖条:模版设计 / 文字 / 素材库,文字单独一栏 -->
|
|
|
+ <div class="toolbar-body">
|
|
|
+ <nav class="toolbar-vertical-nav">
|
|
|
+ <button type="button" class="nav-item" :class="{ active: activeTab === 'design' }" @click="activeTab = 'design'">
|
|
|
+ <el-icon class="nav-item-icon"><EditPen /></el-icon>
|
|
|
+ <span class="nav-item-text">上传</span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="nav-item" :class="{ active: activeTab === 'text' }" @click="activeTab = 'text'">
|
|
|
+ <el-icon class="nav-item-icon"><Document /></el-icon>
|
|
|
+ <span class="nav-item-text">文字</span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="nav-item" :class="{ active: activeTab === 'material' }" @click="activeTab = 'material'; fetchMaterials()">
|
|
|
+ <el-icon class="nav-item-icon"><Box /></el-icon>
|
|
|
+ <span class="nav-item-text">素材库</span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="nav-item" :class="{ active: activeTab === 'ai' }" @click="activeTab = 'ai'">
|
|
|
+ <el-icon class="nav-item-icon"><MagicStick /></el-icon>
|
|
|
+ <span class="nav-item-text">AI设计</span>
|
|
|
+ </button>
|
|
|
+ </nav>
|
|
|
+ <div class="toolbar-right">
|
|
|
+ <!-- 可滚动区固定高度,切换 tab 时画布不跳动;底部按钮固定 -->
|
|
|
+ <div class="toolbar-tabs-content">
|
|
|
+ <div v-show="activeTab === 'design'" class="toolbar-pane toolbar-pane-scroll">
|
|
|
+ <!-- 操作指引 -->
|
|
|
+ <div class="step-guide">
|
|
|
+ <div class="step-guide-title">操作步骤</div>
|
|
|
+ <div class="step-guide-steps">
|
|
|
+ <div class="step-item"><span class="step-num">1</span> 上传素材图片或从「素材库」选择</div>
|
|
|
+ <div class="step-item"><span class="step-num">2</span> 在「文字」标签中点击 标题/副标题/正文 添加文本</div>
|
|
|
+ <div class="step-item"><span class="step-num">3</span> 在画布上拖拽、调整图层</div>
|
|
|
+ <div class="step-item"><span class="step-num">4</span> 完成设计后 点击底部「生成模版」</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-upload
|
|
|
+ class="custom-upload"
|
|
|
+ drag
|
|
|
+ :show-file-list="false"
|
|
|
+ :before-upload="beforeUpload"
|
|
|
+ :http-request="handleUpload"
|
|
|
+ accept="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">
|
|
|
+ 拖拽或点击上传PNG 10M以内
|
|
|
+ </div>
|
|
|
+ </el-upload>
|
|
|
+ </div>
|
|
|
+ <!-- 文字:单独一栏,点击添加文本 + 标题/副标题/正文 -->
|
|
|
+ <div v-show="activeTab === 'text'" class="toolbar-pane toolbar-pane-scroll text-tab-pane">
|
|
|
+ <p class="text-add-heading">点击添加文本</p>
|
|
|
+ <div class="text-add-btns">
|
|
|
+ <button type="button" class="text-add-btn text-add-btn-title" @click="addTextLayer('title')">标题</button>
|
|
|
+ <button type="button" class="text-add-btn" @click="addTextLayer('subtitle')">副标题</button>
|
|
|
+ <button type="button" class="text-add-btn" @click="addTextLayer('body')">正文</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- AI 工具:需选中画布中的图片,未选中时提示 -->
|
|
|
+ <div v-show="activeTab === 'ai'" class="toolbar-pane toolbar-pane-scroll ai-tools-pane">
|
|
|
+ <div class="ai-tools-section">
|
|
|
+ <div class="ai-tools-title">图片工具</div>
|
|
|
+ <div
|
|
|
+ v-for="item in aiToolList"
|
|
|
+ :key="item.key"
|
|
|
+ class="ai-tool-item"
|
|
|
+ @click="handleAiToolClick(item)"
|
|
|
+ >
|
|
|
+ <div class="ai-tool-icon" :class="item.iconClass">
|
|
|
+ <span class="ai-tool-icon-inner">{{ item.iconText }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="ai-tool-body">
|
|
|
+ <div class="ai-tool-name">{{ item.name }}</div>
|
|
|
+ <div class="ai-tool-desc">{{ item.desc }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-show="activeTab === 'material'" class="toolbar-pane toolbar-pane-scroll">
|
|
|
+ <div class="materials-panel">
|
|
|
+ <el-input
|
|
|
+ v-model="materialSearch"
|
|
|
+ class="materials-search"
|
|
|
+ size="small"
|
|
|
+ clearable
|
|
|
+ :prefix-icon="Search"
|
|
|
+ placeholder="搜索素材"
|
|
|
+ @input="handleMaterialSearchInput"
|
|
|
+ @clear="handleMaterialSearchInput"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="materials-type-chips">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="materials-chip"
|
|
|
+ :class="{ active: activeMaterialType === '全部' }"
|
|
|
+ @click="setActiveMaterialType('全部')"
|
|
|
+ >
|
|
|
+ 全部
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ v-for="t in materialTypeStats"
|
|
|
+ :key="t.type"
|
|
|
+ type="button"
|
|
|
+ class="materials-chip"
|
|
|
+ :class="{ active: activeMaterialType === t.type }"
|
|
|
+ @click="setActiveMaterialType(t.type)"
|
|
|
+ >
|
|
|
+ {{ t.type }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-skeleton v-if="materialsLoading" :rows="5" animated />
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <!-- 搜索时:直接展示结果列表 -->
|
|
|
+ <div v-if="materialSearch.trim()" class="materials-list-full">
|
|
|
+ <div v-if="materialsAfterSearch.length === 0" class="empty-materials">
|
|
|
+ 暂无素材
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-else
|
|
|
+ class="material-item-full"
|
|
|
+ v-for="material in materialsAfterSearch"
|
|
|
+ :key="material.id"
|
|
|
+ @click="addMaterialToCanvas(material)"
|
|
|
+ >
|
|
|
+ <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" />
|
|
|
+ <div class="material-overlay">
|
|
|
+ <el-icon><Plus /></el-icon>
|
|
|
+ <span>添加</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 分类明细:点击“更多”或顶部标签进入 -->
|
|
|
+ <div v-else-if="materialViewMode === 'detail'" class="materials-detail">
|
|
|
+ <div class="materials-detail-header">
|
|
|
+ <button type="button" class="materials-back" @click="backToMaterialCategory">
|
|
|
+ <el-icon><ArrowLeft /></el-icon>
|
|
|
+ </button>
|
|
|
+ <span class="materials-detail-title">{{ activeMaterialType }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="materials-list-full">
|
|
|
+ <div v-if="detailMaterials.length === 0" class="empty-materials">
|
|
|
+ 暂无素材
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-else
|
|
|
+ class="material-item-full"
|
|
|
+ v-for="material in detailMaterials"
|
|
|
+ :key="material.id"
|
|
|
+ @click="addMaterialToCanvas(material)"
|
|
|
+ >
|
|
|
+ <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" />
|
|
|
+ <div class="material-overlay">
|
|
|
+ <el-icon><Plus /></el-icon>
|
|
|
+ <span>添加</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 分类总览:按分类分组展示(每组预览部分 + 更多) -->
|
|
|
+ <div v-else class="materials-groups">
|
|
|
+ <div
|
|
|
+ v-for="g in materialTypeStats"
|
|
|
+ :key="g.type"
|
|
|
+ class="materials-group"
|
|
|
+ >
|
|
|
+ <div class="materials-group-header">
|
|
|
+ <span class="materials-group-title">{{ g.type }}</span>
|
|
|
+ <button
|
|
|
+ v-if="(materialsByType.get(g.type)?.length || 0) > 0"
|
|
|
+ type="button"
|
|
|
+ class="materials-more"
|
|
|
+ @click="openMaterialTypeDetail(g.type)"
|
|
|
+ >
|
|
|
+ 更多 >
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="materials-list-full materials-list-preview">
|
|
|
+ <div
|
|
|
+ class="material-item-full"
|
|
|
+ v-for="material in (materialsByType.get(g.type) || []).slice(0, PREVIEW_LIMIT)"
|
|
|
+ :key="material.id"
|
|
|
+ @click="addMaterialToCanvas(material)"
|
|
|
+ >
|
|
|
+ <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" />
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-action-footer">
|
|
|
+ <el-button type="primary" class="save-template-btn" @click="saveTemplate">
|
|
|
+ {{ editingTemplateId ? '保存修改' : '生成模版' }}
|
|
|
+ </el-button>
|
|
|
+ <div class="save-template-tip">{{ editingTemplateId ? '修改后点击上方按钮保存,将覆盖原模版' : '完成设计后点击上方按钮生成并保存' }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧内容区域 -->
|
|
|
+ <div class="content-area">
|
|
|
+ <!-- 中间画布区域 -->
|
|
|
+ <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"
|
|
|
+ @contextmenu.prevent
|
|
|
+ >
|
|
|
+ <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">
|
|
|
+ <div class="right-panel-menu">
|
|
|
+ <button type="button" class="right-panel-menu-item" :class="{ active: rightPanelTab === 'layer' }" @click="rightPanelTab = 'layer'">
|
|
|
+ <el-icon><List /></el-icon>
|
|
|
+ <span>图层管理</span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="right-panel-menu-item" :class="{ active: rightPanelTab === 'props' }" @click="rightPanelTab = 'props'">
|
|
|
+ <el-icon><Setting /></el-icon>
|
|
|
+ <span>调整</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <!-- 图层管理 -->
|
|
|
+ <div v-show="rightPanelTab === 'layer'" class="right-section">
|
|
|
+ <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>
|
|
|
+ <div class="layer-list" ref="layerListRef">
|
|
|
+ <div
|
|
|
+ v-for="(layer, index) in reversedLayers"
|
|
|
+ :key="layer.id"
|
|
|
+ class="layer-item-card"
|
|
|
+ :class="{
|
|
|
+ 'selected': selectedLayerId === layer.id,
|
|
|
+ 'dragging': dragState.draggingId === layer.id,
|
|
|
+ 'drag-over': dragState.dragOverId === layer.id,
|
|
|
+ 'locked': layer.locked
|
|
|
+ }"
|
|
|
+ :draggable="!layer.locked"
|
|
|
+ @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="layer-drag-handle" @click.stop><Rank /></el-icon>
|
|
|
+ <template v-if="layer.type === 'text'">
|
|
|
+ <div class="layer-card-thumb text-thumb">T</div>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <img v-if="layer.url" :src="layer.url" class="layer-card-thumb" alt="" />
|
|
|
+ <div v-else class="layer-card-thumb img-placeholder"><el-icon><Picture /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ <span class="layer-card-name">{{ layer.name || (layer.type === 'text' ? '文字' : '图片') }}</span>
|
|
|
+ <el-icon class="layer-card-action" :title="layer.visible ? '隐藏' : '显示'" @click.stop="toggleLayerVisibility(layer.id)">
|
|
|
+ <View v-if="layer.visible" />
|
|
|
+ <Hide v-else />
|
|
|
+ </el-icon>
|
|
|
+ <el-icon class="layer-card-action" :title="layer.locked ? '解锁' : '锁定'" @click.stop="toggleLayerLock(layer.id)">
|
|
|
+ <Lock v-if="layer.locked" />
|
|
|
+ <Unlock v-else />
|
|
|
+ </el-icon>
|
|
|
+ <el-icon class="layer-card-action" title="删除" @click.stop="deleteLayerById(layer.id)">
|
|
|
+ <Delete />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div v-if="layers.length === 0" class="empty-tip">暂无图层,请上传素材</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 调整:画布尺寸、图层属性、文字属性 全部展开不隐藏 -->
|
|
|
+ <div v-show="rightPanelTab === 'props'" class="right-section right-section-props">
|
|
|
+ <section class="props-block">
|
|
|
+ <h4 class="props-block-title">画布尺寸</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>
|
|
|
+ </section>
|
|
|
+ <section class="props-block">
|
|
|
+ <h4 class="props-block-title">图层属性</h4>
|
|
|
+ <div v-if="!selectedLayer" class="layer-info-empty">选择画布上的图层后可编辑</div>
|
|
|
+ <template v-else>
|
|
|
+ <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>
|
|
|
+ <div v-if="selectedLayer.type !== 'text'" class="property-item">
|
|
|
+ <span>等比例缩放</span>
|
|
|
+ <el-switch v-model="maintainAspectRatio" />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </section>
|
|
|
+ <section class="props-block">
|
|
|
+ <h4 class="props-block-title">文字属性</h4>
|
|
|
+ <div v-if="!selectedLayer || selectedLayer.type !== 'text'" class="layer-info-empty">选择文字图层后可编辑</div>
|
|
|
+ <template v-else>
|
|
|
+ <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;" filterable clearable placeholder="搜索字体">
|
|
|
+ <el-option label="Arial" value="Arial" />
|
|
|
+ <el-option label="Helvetica" value="Helvetica" />
|
|
|
+ <el-option label="宋体" value="SimSun" />
|
|
|
+ <el-option label="黑体" value="SimHei" />
|
|
|
+ <el-option label="微软雅黑" value="Microsoft YaHei" />
|
|
|
+ <el-option label="楷体" value="KaiTi" />
|
|
|
+ <el-option label="仿宋" value="FangSong" />
|
|
|
+ <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>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- AI 工具未选图片时的提示弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="aiPromptDialogVisible"
|
|
|
+ :title="null"
|
|
|
+ width="400px"
|
|
|
+ align-center
|
|
|
+ class="ai-prompt-dialog"
|
|
|
+ :show-close="true"
|
|
|
+ @close="aiPromptDialogVisible = false"
|
|
|
+ >
|
|
|
+ <div class="ai-prompt-content">
|
|
|
+ <div class="ai-prompt-icon-wrap">
|
|
|
+ <el-icon class="ai-prompt-icon"><InfoFilled /></el-icon>
|
|
|
+ </div>
|
|
|
+ <p class="ai-prompt-text">使用{{ aiPromptToolName }}前,请在画布模版中先选择一张图片~</p>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, reactive, onMounted, onUnmounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import { Rank, ArrowUp, ArrowDown, Delete, View, Hide, Plus, Picture, Upload, Search, ArrowLeft, EditPen, Document, Box, MagicStick, InfoFilled, Lock, Unlock, List, Setting } from '@element-plus/icons-vue'
|
|
|
+import { emitter } from '@/utils/bus.js'
|
|
|
+import { useUserStore } from '@/pinia/modules/user'
|
|
|
+import { Material_List, Template_Material_Add, Template_Material_Update, Template_Material_Relation } from '@/api/mes/job'
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ /** 列表页传入的模版对象(编辑/做同款时必传) */
|
|
|
+ initialTemplate: { type: Object, default: null },
|
|
|
+ /** create=新建, edit=编辑, copy=做同款 */
|
|
|
+ mode: { type: String, default: 'create' }
|
|
|
+})
|
|
|
+const emit = defineEmits(['back'])
|
|
|
+
|
|
|
+const canvasRef = ref(null)
|
|
|
+const canvasAreaRef = ref(null)
|
|
|
+const layerListRef = ref(null)
|
|
|
+const currentTool = ref('select')
|
|
|
+const activeTab = ref('design') // 'design' | 'text' | 'material' | 'ai'
|
|
|
+// 右侧菜单:图层管理 | 调整
|
|
|
+const rightPanelTab = ref('layer')
|
|
|
+// AI 工具:未选图片时提示弹窗
|
|
|
+const aiPromptDialogVisible = ref(false)
|
|
|
+const aiPromptToolName = ref('')
|
|
|
+const aiToolList = [
|
|
|
+ { key: 'cutout', name: '智能抠图', desc: '一键抠图,快速去除背景。', iconClass: 'ai-icon-cutout', iconText: '抠图' },
|
|
|
+ { key: 'hd', name: '变清晰', desc: '超清画质重生,告别渣画质。', iconClass: 'ai-icon-hd', iconText: '清' },
|
|
|
+ { key: 'expand', name: 'AI扩图', desc: '在原图基础上对画面进行拓展。', iconClass: 'ai-icon-expand', iconText: '扩图' },
|
|
|
+ { key: 'generate', name: 'AI生图', desc: '输入文字或参考图,AI根据描述内容生成新的素材图片。', iconClass: 'ai-icon-generate', iconText: '文图' }
|
|
|
+]
|
|
|
+function handleAiToolClick(item) {
|
|
|
+ const layer = selectedLayer.value
|
|
|
+ const isImageLayer = layer && layer.type !== 'text'
|
|
|
+ if (!isImageLayer) {
|
|
|
+ aiPromptToolName.value = item.name
|
|
|
+ aiPromptDialogVisible.value = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ElMessage.info('功能开发中')
|
|
|
+}
|
|
|
+function goUploadImage() {
|
|
|
+ aiPromptDialogVisible.value = false
|
|
|
+ activeTab.value = 'design'
|
|
|
+}
|
|
|
+// 本页为创建/编辑页,无 currentView 切换
|
|
|
+const canvasWidth = ref(600)
|
|
|
+const canvasHeight = ref(450)
|
|
|
+const canvasRatio = ref('4:3')
|
|
|
+const zoomLevel = ref(100)
|
|
|
+
|
|
|
+// 当前是否为「预览后编辑」:有值表示正在编辑该 id 的模版,保存时走更新接口
|
|
|
+const editingTemplateId = ref(null)
|
|
|
+// 当前模版名称(设计页顶部 contenteditable 直接编辑,保存时提交 template_name)
|
|
|
+const templateName = ref('未命名模版')
|
|
|
+const templateNameElRef = ref(null)
|
|
|
+const TEMPLATE_NAME_MAX_LEN = 12
|
|
|
+const syncTemplateNameFromEl = () => {
|
|
|
+ const el = templateNameElRef.value
|
|
|
+ if (!el) return
|
|
|
+ const text = (el.textContent || '').trim().slice(0, TEMPLATE_NAME_MAX_LEN)
|
|
|
+ templateName.value = text || '未命名模版'
|
|
|
+ el.textContent = templateName.value
|
|
|
+}
|
|
|
+const enforceTemplateNameMaxLength = () => {
|
|
|
+ const el = templateNameElRef.value
|
|
|
+ if (!el) return
|
|
|
+ const raw = el.textContent || ''
|
|
|
+ if (raw.length <= TEMPLATE_NAME_MAX_LEN) return
|
|
|
+ ElMessage.warning('模版名称最多12个字符')
|
|
|
+ el.textContent = raw.slice(0, TEMPLATE_NAME_MAX_LEN)
|
|
|
+ el.focus()
|
|
|
+ const sel = window.getSelection()
|
|
|
+ if (sel) {
|
|
|
+ const range = document.createRange()
|
|
|
+ range.selectNodeContents(el)
|
|
|
+ range.collapse(false)
|
|
|
+ sel.removeAllRanges()
|
|
|
+ sel.addRange(range)
|
|
|
+ }
|
|
|
+}
|
|
|
+const focusTemplateNameEl = () => {
|
|
|
+ templateNameElRef.value?.focus()
|
|
|
+}
|
|
|
+const setTemplateNameElContent = (value) => {
|
|
|
+ const el = templateNameElRef.value
|
|
|
+ if (el && document.activeElement !== el) {
|
|
|
+ el.textContent = value || '未命名模版'
|
|
|
+ }
|
|
|
+}
|
|
|
+const templatesLoading = ref(false) // 加载模版数据时使用
|
|
|
+
|
|
|
+
|
|
|
+//获取登录用户信息
|
|
|
+const userStore = useUserStore()
|
|
|
+const _username = ref('')
|
|
|
+_username.value = userStore.userInfo.userName + '/' + userStore.userInfo.nickName
|
|
|
+console.log('获取用户信息',_username.value)
|
|
|
+console.log('获取用户名称',userStore.userInfo.nickName)
|
|
|
+
|
|
|
+const layers = ref([])
|
|
|
+const selectedLayerId = ref(null)
|
|
|
+const maintainAspectRatio = ref(false) // 默认不锁定宽高比例,宽高可自由调整
|
|
|
+
|
|
|
+// 拖拽状态
|
|
|
+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
|
|
|
+
|
|
|
+// 将 material_url 转为前端可请求的完整地址(相对路径加根路径,避免图片 404)
|
|
|
+function resolveMaterialUrl(path) {
|
|
|
+ if (!path || typeof path !== 'string') return ''
|
|
|
+ const p = path.trim()
|
|
|
+ if (p.startsWith('http://') || p.startsWith('https://') || p.startsWith('data:')) return p
|
|
|
+ return p.startsWith('/') ? p : '/' + p
|
|
|
+}
|
|
|
+
|
|
|
+// 清空画布,进入新建流程(本组件内部或由 props.mode='create' 触发)
|
|
|
+const clearDesign = () => {
|
|
|
+ editingTemplateId.value = null
|
|
|
+ templateName.value = '未命名模版'
|
|
|
+ layers.value = []
|
|
|
+ selectedLayerId.value = null
|
|
|
+}
|
|
|
+
|
|
|
+// 使用模板:通过模板 id 获取模板关联的图层数据并还原到画布(预览后可编辑并保存修改)
|
|
|
+const useTemplate = async (template) => {
|
|
|
+ if (!template || !template.id) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ templatesLoading.value = true
|
|
|
+ const res = await Template_Material_Relation({ id: template.id })
|
|
|
+
|
|
|
+ if (!res || res.code !== 0) {
|
|
|
+ ElMessage.error(res?.msg || '获取模板数据失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = res.data
|
|
|
+ let rows = Array.isArray(data) ? data : (data?.layers ?? data?.data ?? [])
|
|
|
+
|
|
|
+ if (!Array.isArray(rows) || !rows.length) {
|
|
|
+ ElMessage.error('暂无图层数据')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 画布参数:优先用接口返回的,没有则用模板列表项上的
|
|
|
+ const canvasW = data?.canvasWidth ?? template?.canvasWidth
|
|
|
+ const canvasH = data?.canvasHeight ?? template?.canvasHeight
|
|
|
+ const sizeRatio = data?.size ?? template?.size ?? template?.canvasRatio
|
|
|
+
|
|
|
+ if (canvasW != null && canvasW !== '') canvasWidth.value = Number(canvasW) || canvasWidth.value
|
|
|
+ if (canvasH != null && canvasH !== '') canvasHeight.value = Number(canvasH) || canvasHeight.value
|
|
|
+ if (sizeRatio != null && sizeRatio !== '') canvasRatio.value = String(sizeRatio)
|
|
|
+
|
|
|
+ layers.value = rows.map(row => {
|
|
|
+ const width = Number(row.width ?? 100) || 100
|
|
|
+ const height = Number(row.height ?? 100) || 100
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: ++layerIdCounter,
|
|
|
+ name: row.layer_name,
|
|
|
+ type: row.layer_type === 'text' ? 'text' : 'image',
|
|
|
+ url: resolveMaterialUrl(row.material_url || ''),
|
|
|
+ text: row.text_content || '',
|
|
|
+ x: Number(row.position_x ?? 0) || 0,
|
|
|
+ y: Number(row.position_y ?? 0) || 0,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ rotation: Number(row.rotation ?? 0) || 0,
|
|
|
+ opacity: Number(row.opacity ?? 100) || 100,
|
|
|
+ visible: String(row.visible ?? '1') !== '0',
|
|
|
+ locked: String(row.locked ?? '0') === '1',
|
|
|
+ fontFamily: row.font_family || 'Arial',
|
|
|
+ fontSize: Number(row.font_size || 16) || 16,
|
|
|
+ color: row.font_color || '#000000',
|
|
|
+ backgroundColor: row.background_color || 'transparent',
|
|
|
+ textAlign: row.text_align || 'left',
|
|
|
+ fontWeight: row.font_weight || 'normal',
|
|
|
+ fontStyle: row.font_style || 'normal',
|
|
|
+ textDecoration: row.font_underline === 'underline' ? 'underline' : 'none',
|
|
|
+ lineHeight: Number(row.line_height || 1.5) || 1.5,
|
|
|
+ letterSpacing: Number(row.letter_spacing || 0) || 0,
|
|
|
+ originalWidth: width,
|
|
|
+ originalHeight: height,
|
|
|
+ zIndex: Number(row.z_index || 0) || 0,
|
|
|
+ materialId: row.material_id,
|
|
|
+ templateId: row.template_id
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 选中顶部图层
|
|
|
+ selectedLayerId.value = layers.value.length ? layers.value[layers.value.length - 1].id : null
|
|
|
+ activeTab.value = 'design'
|
|
|
+ editingTemplateId.value = template.id // 预览后编辑,保存时走更新接口
|
|
|
+ templateName.value = template.template_name || '未命名模版'
|
|
|
+ } catch (e) {
|
|
|
+ console.error('useTemplate error:', e)
|
|
|
+ ElMessage.error('获取模板数据失败')
|
|
|
+ } finally {
|
|
|
+ templatesLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 做同款:加载他人模版到画布,清除编辑 id,保存时走新增接口
|
|
|
+const useAsTemplate = async (template) => {
|
|
|
+ if (!template || !template.id) return
|
|
|
+ editingTemplateId.value = null // 避免从「我的作品」编辑后再做同款时仍走保存修改
|
|
|
+ try {
|
|
|
+ templatesLoading.value = true
|
|
|
+ const res = await Template_Material_Relation({ id: template.id })
|
|
|
+ if (!res || res.code !== 0) {
|
|
|
+ ElMessage.error(res?.msg || '获取模板数据失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const data = res.data
|
|
|
+ let rows = Array.isArray(data) ? data : (data?.layers ?? data?.data ?? [])
|
|
|
+ if (!Array.isArray(rows) || !rows.length) {
|
|
|
+ ElMessage.error('暂无图层数据')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const canvasW = data?.canvasWidth ?? template?.canvasWidth
|
|
|
+ const canvasH = data?.canvasHeight ?? template?.canvasHeight
|
|
|
+ const sizeRatio = data?.size ?? template?.size ?? template?.canvasRatio
|
|
|
+ if (canvasW != null && canvasW !== '') canvasWidth.value = Number(canvasW) || canvasWidth.value
|
|
|
+ if (canvasH != null && canvasH !== '') canvasHeight.value = Number(canvasH) || canvasHeight.value
|
|
|
+ if (sizeRatio != null && sizeRatio !== '') canvasRatio.value = String(sizeRatio)
|
|
|
+ layers.value = rows.map(row => {
|
|
|
+ const width = Number(row.width ?? 100) || 100
|
|
|
+ const height = Number(row.height ?? 100) || 100
|
|
|
+ return {
|
|
|
+ id: ++layerIdCounter,
|
|
|
+ name: row.layer_name,
|
|
|
+ type: row.layer_type === 'text' ? 'text' : 'image',
|
|
|
+ url: resolveMaterialUrl(row.material_url || ''),
|
|
|
+ text: row.text_content || '',
|
|
|
+ x: Number(row.position_x ?? 0) || 0,
|
|
|
+ y: Number(row.position_y ?? 0) || 0,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ rotation: Number(row.rotation ?? 0) || 0,
|
|
|
+ opacity: Number(row.opacity ?? 100) || 100,
|
|
|
+ visible: String(row.visible ?? '1') !== '0',
|
|
|
+ locked: String(row.locked ?? '0') === '1',
|
|
|
+ fontFamily: row.font_family || 'Arial',
|
|
|
+ fontSize: Number(row.font_size || 16) || 16,
|
|
|
+ color: row.font_color || '#000000',
|
|
|
+ backgroundColor: row.background_color || 'transparent',
|
|
|
+ textAlign: row.text_align || 'left',
|
|
|
+ fontWeight: row.font_weight || 'normal',
|
|
|
+ fontStyle: row.font_style || 'normal',
|
|
|
+ textDecoration: row.font_underline === 'underline' ? 'underline' : 'none',
|
|
|
+ lineHeight: Number(row.line_height || 1.5) || 1.5,
|
|
|
+ letterSpacing: Number(row.letter_spacing || 0) || 0,
|
|
|
+ originalWidth: width,
|
|
|
+ originalHeight: height,
|
|
|
+ zIndex: Number(row.z_index || 0) || 0,
|
|
|
+ materialId: row.material_id,
|
|
|
+ templateId: row.template_id
|
|
|
+ }
|
|
|
+ })
|
|
|
+ selectedLayerId.value = layers.value.length ? layers.value[layers.value.length - 1].id : null
|
|
|
+ activeTab.value = 'design'
|
|
|
+ templateName.value = template.template_name ? template.template_name + ' (同款)' : '未命名模版'
|
|
|
+ // 不设置 editingTemplateId,保存时走 Template_Material_Add 新增
|
|
|
+ } catch (e) {
|
|
|
+ console.error('useAsTemplate error:', e)
|
|
|
+ ElMessage.error('获取模板数据失败')
|
|
|
+ } finally {
|
|
|
+ templatesLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 根据 props 加载:新建 / 编辑 / 做同款
|
|
|
+const loadByMode = () => {
|
|
|
+ const mode = props.mode || 'create'
|
|
|
+ const template = props.initialTemplate
|
|
|
+ if (mode === 'create' || !template) {
|
|
|
+ clearDesign()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (mode === 'edit') {
|
|
|
+ useTemplate(template)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (mode === 'copy') {
|
|
|
+ useAsTemplate(template)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+watch([() => props.mode, () => props.initialTemplate], loadByMode)
|
|
|
+
|
|
|
+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 TEXT_PRESETS = {
|
|
|
+ title: { fontSize: 32, fontWeight: 'bold', text: '标题文字', name: '标题' },
|
|
|
+ subtitle: { fontSize: 22, fontWeight: 'normal', text: '副标题', name: '副标题' },
|
|
|
+ body: { fontSize: 16, fontWeight: 'normal', text: '正文内容', name: '正文' }
|
|
|
+}
|
|
|
+
|
|
|
+// 添加文字图层,preset 可选 'title' | 'subtitle' | 'body'
|
|
|
+const addTextLayer = (preset) => {
|
|
|
+ const def = preset ? TEXT_PRESETS[preset] : { fontSize: 16, fontWeight: 'normal', text: '双击编辑文字', name: '文字' }
|
|
|
+ const count = textLayerCount.value + 1
|
|
|
+ const name = preset ? `${def.name} ${count}` : '文字 ' + count
|
|
|
+ const newLayer = {
|
|
|
+ id: ++layerIdCounter,
|
|
|
+ name,
|
|
|
+ type: 'text',
|
|
|
+ text: def.text,
|
|
|
+ x: canvasWidth.value / 2 - 50,
|
|
|
+ y: canvasHeight.value / 2 - 15,
|
|
|
+ width: 120,
|
|
|
+ height: Math.max(30, def.fontSize + 8),
|
|
|
+ rotation: 0,
|
|
|
+ opacity: 100,
|
|
|
+ visible: true,
|
|
|
+ locked: false,
|
|
|
+ fontSize: def.fontSize,
|
|
|
+ fontFamily: 'Arial',
|
|
|
+ fontWeight: def.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++
|
|
|
+}
|
|
|
+
|
|
|
+const textLayerCount = ref(0)
|
|
|
+
|
|
|
+// 素材库状态
|
|
|
+const materials = ref([])
|
|
|
+const materialsLoading = ref(false)
|
|
|
+const materialSearch = ref('')
|
|
|
+const activeMaterialType = ref('全部') // 顶部标签选中的分类("全部" / 某个type)
|
|
|
+const materialViewMode = ref('category') // category: 按分类展示;detail: 单分类明细
|
|
|
+
|
|
|
+const PREVIEW_LIMIT = 4
|
|
|
+
|
|
|
+const materialsAfterSearch = computed(() => {
|
|
|
+ // 接口已支持 search,但仍做前端兜底过滤(避免接口不返回预期)
|
|
|
+ const kw = (materialSearch.value || '').trim().toLowerCase()
|
|
|
+ const list = materials.value || []
|
|
|
+ if (!kw) return list
|
|
|
+ return list.filter(m => {
|
|
|
+ const hay = `${m?.type || ''} ${m?.material_url || ''} ${m?.sys_id || ''}`.toLowerCase()
|
|
|
+ return hay.includes(kw)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// 红框:按 type 汇总 count,并按数量从大到小排序
|
|
|
+const materialTypeStats = computed(() => {
|
|
|
+ const map = new Map()
|
|
|
+ for (const m of materialsAfterSearch.value) {
|
|
|
+ const t = m?.type || '未分类'
|
|
|
+ const c = Number(m?.count ?? 1) || 1
|
|
|
+ map.set(t, (map.get(t) || 0) + c)
|
|
|
+ }
|
|
|
+ return Array.from(map.entries())
|
|
|
+ .map(([type, totalCount]) => ({ type, totalCount }))
|
|
|
+ .sort((a, b) => b.totalCount - a.totalCount)
|
|
|
+})
|
|
|
+
|
|
|
+const materialsByType = computed(() => {
|
|
|
+ const map = new Map()
|
|
|
+ for (const m of materialsAfterSearch.value) {
|
|
|
+ const t = m?.type || '未分类'
|
|
|
+ if (!map.has(t)) map.set(t, [])
|
|
|
+ map.get(t).push(m)
|
|
|
+ }
|
|
|
+ // 每个分类内部也按 count 降序,保证热门在前
|
|
|
+ for (const [t, list] of map.entries()) {
|
|
|
+ list.sort((a, b) => (Number(b?.count ?? 1) || 1) - (Number(a?.count ?? 1) || 1))
|
|
|
+ }
|
|
|
+ return map
|
|
|
+})
|
|
|
+
|
|
|
+const detailMaterials = computed(() => {
|
|
|
+ if (activeMaterialType.value === '全部') return materialsAfterSearch.value
|
|
|
+ return (materialsByType.value.get(activeMaterialType.value) || [])
|
|
|
+})
|
|
|
+
|
|
|
+// 获取素材库数据
|
|
|
+const fetchMaterials = async () => {
|
|
|
+ console.log('fetchMaterials called')
|
|
|
+ try {
|
|
|
+ materialsLoading.value = true
|
|
|
+ console.log('开始获取素材库数据')
|
|
|
+ const response = await Material_List({
|
|
|
+ search: (materialSearch.value || '').trim() || undefined
|
|
|
+ })
|
|
|
+ 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('获取素材库完成')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 生成画布预览图(base64)
|
|
|
+const generateCanvasPreview = async () => {
|
|
|
+ if (!canvasWidth.value || !canvasHeight.value) return null
|
|
|
+
|
|
|
+ const exportCanvas = document.createElement('canvas')
|
|
|
+ exportCanvas.width = canvasWidth.value
|
|
|
+ exportCanvas.height = canvasHeight.value
|
|
|
+ const ctx = exportCanvas.getContext('2d')
|
|
|
+ if (!ctx) return null
|
|
|
+
|
|
|
+ // 背景填充为白色
|
|
|
+ ctx.fillStyle = '#ffffff'
|
|
|
+ ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
|
|
|
+
|
|
|
+ // 预加载图片图层
|
|
|
+ const imageLayers = layers.value.filter(l => (l.type || 'image') !== 'text' && l.url)
|
|
|
+ await Promise.all(
|
|
|
+ imageLayers.map(layer => {
|
|
|
+ return new Promise(resolve => {
|
|
|
+ const img = new Image()
|
|
|
+ img.crossOrigin = 'anonymous'
|
|
|
+ img.onload = () => {
|
|
|
+ layer._previewImg = img
|
|
|
+ resolve()
|
|
|
+ }
|
|
|
+ img.onerror = () => resolve()
|
|
|
+ img.src = layer.url
|
|
|
+ })
|
|
|
+ })
|
|
|
+ )
|
|
|
+
|
|
|
+ // 按当前顺序绘制所有可见图层
|
|
|
+ for (const layer of layers.value) {
|
|
|
+ if (!layer.visible) continue
|
|
|
+
|
|
|
+ ctx.save()
|
|
|
+ ctx.globalAlpha = (layer.opacity ?? 100) / 100
|
|
|
+
|
|
|
+ const w = layer.width || 0
|
|
|
+ const h = layer.height || 0
|
|
|
+ const cx = (layer.x || 0) + w / 2
|
|
|
+ const cy = (layer.y || 0) + h / 2
|
|
|
+
|
|
|
+ ctx.translate(cx, cy)
|
|
|
+ ctx.rotate(((layer.rotation || 0) * Math.PI) / 180)
|
|
|
+
|
|
|
+ if ((layer.type || 'image') === 'text') {
|
|
|
+ const fontSize = layer.fontSize || 16
|
|
|
+ const fontFamily = layer.fontFamily || 'Arial'
|
|
|
+ const fontWeight = layer.fontWeight || 'normal'
|
|
|
+ const fontStyle = layer.fontStyle || 'normal'
|
|
|
+ ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
|
|
|
+ ctx.textAlign = layer.textAlign || 'left'
|
|
|
+ ctx.textBaseline = 'top'
|
|
|
+ ctx.fillStyle = layer.color || '#000000'
|
|
|
+
|
|
|
+ const lineHeightPx = (layer.lineHeight || 1.5) * fontSize
|
|
|
+ const lines = (layer.text || '').split('\n')
|
|
|
+
|
|
|
+ let startX = 0
|
|
|
+ if (ctx.textAlign === 'left') {
|
|
|
+ startX = -w / 2 + 4
|
|
|
+ } else if (ctx.textAlign === 'right') {
|
|
|
+ startX = w / 2 - 4
|
|
|
+ } // center 默认 0
|
|
|
+
|
|
|
+ let y = -h / 2 + 4
|
|
|
+ for (const line of lines) {
|
|
|
+ ctx.fillText(line, startX, y)
|
|
|
+ y += lineHeightPx
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const img = layer._previewImg
|
|
|
+ if (img) {
|
|
|
+ ctx.drawImage(img, -w / 2, -h / 2, w, h)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx.restore()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理临时图片引用
|
|
|
+ for (const layer of imageLayers) {
|
|
|
+ delete layer._previewImg
|
|
|
+ }
|
|
|
+
|
|
|
+ return exportCanvas.toDataURL('image/png')
|
|
|
+}
|
|
|
+
|
|
|
+let materialSearchTimer = null
|
|
|
+const handleMaterialSearchInput = () => {
|
|
|
+ if (materialSearchTimer) clearTimeout(materialSearchTimer)
|
|
|
+ materialSearchTimer = setTimeout(() => {
|
|
|
+ fetchMaterials()
|
|
|
+ }, 250)
|
|
|
+}
|
|
|
+
|
|
|
+const setActiveMaterialType = (t) => {
|
|
|
+ activeMaterialType.value = t
|
|
|
+ materialViewMode.value = t === '全部' ? 'category' : 'detail'
|
|
|
+}
|
|
|
+
|
|
|
+const openMaterialTypeDetail = (t) => {
|
|
|
+ activeMaterialType.value = t
|
|
|
+ materialViewMode.value = 'detail'
|
|
|
+}
|
|
|
+
|
|
|
+const backToMaterialCategory = () => {
|
|
|
+ activeMaterialType.value = '全部'
|
|
|
+ materialViewMode.value = 'category'
|
|
|
+}
|
|
|
+
|
|
|
+// 标签页点击事件
|
|
|
+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: resolveMaterialUrl(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,
|
|
|
+ materialId: material.id ?? material.material_id ?? null
|
|
|
+ }
|
|
|
+
|
|
|
+ layers.value.push(newLayer)
|
|
|
+ selectedLayerId.value = newLayer.id
|
|
|
+ // ElMessage.success('素材已添加到画布!')
|
|
|
+ }
|
|
|
+ img.src = resolveMaterialUrl(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
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+watch(templateName, (v) => setTemplateNameElContent(v))
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ fetchMaterials()
|
|
|
+ loadByMode()
|
|
|
+ nextTick(() => setTemplateNameElContent(templateName.value))
|
|
|
+ document.addEventListener('click', handleGlobalClick)
|
|
|
+ emitter.emit('templateDesignEnter')
|
|
|
+})
|
|
|
+
|
|
|
+onActivated(() => {
|
|
|
+ emitter.emit('templateDesignEnter')
|
|
|
+})
|
|
|
+
|
|
|
+onDeactivated(() => {
|
|
|
+ emitter.emit('templateDesignLeave')
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ document.removeEventListener('click', handleGlobalClick)
|
|
|
+ emitter.emit('templateDesignLeave')
|
|
|
+})
|
|
|
+
|
|
|
+// 保存模版(生成模版)
|
|
|
+const saveTemplate = async () => {
|
|
|
+ if (!layers.value.length) {
|
|
|
+ ElMessage.warning('请先添加图片或文字,再生成模版')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const previewImage = await generateCanvasPreview()
|
|
|
+
|
|
|
+ const templateData = {
|
|
|
+ template_name: templateName.value || '未命名模版',
|
|
|
+ sys_id:userStore.userInfo.nickName,
|
|
|
+ canvasWidth: canvasWidth.value,
|
|
|
+ canvasHeight: canvasHeight.value,
|
|
|
+ canvasRatio: canvasRatio.value,
|
|
|
+ previewImage, // 画布整体预览图(base64)
|
|
|
+ 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,
|
|
|
+ material_id: layer.materialId ?? layer.material_id ?? '',
|
|
|
+ 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
|
|
|
+ }))
|
|
|
+ }
|
|
|
+
|
|
|
+ const isUpdate = !!editingTemplateId.value
|
|
|
+ try {
|
|
|
+ const res = isUpdate
|
|
|
+ ? await Template_Material_Update({ template_id: editingTemplateId.value, ...templateData })
|
|
|
+ : await Template_Material_Add(templateData)
|
|
|
+ if (res && res.code === 0) {
|
|
|
+ ElMessage.success(isUpdate ? '模版已保存修改!' : '模版生成成功!')
|
|
|
+ editingTemplateId.value = null
|
|
|
+ emit('back')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(res?.msg || (isUpdate ? '模版保存失败' : '模版生成失败'))
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error(isUpdate ? 'Template_Material_Update error:' : 'Template_Material_Add error:', e)
|
|
|
+ ElMessage.error(isUpdate ? '模版保存失败' : '模版生成失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getLayerStyle = (layer) => {
|
|
|
+ if (!layer.visible) {
|
|
|
+ return { display: 'none' }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ left: layer.x + 'px',
|
|
|
+ top: layer.y + 'px',
|
|
|
+ // 文字和图片图层都使用显式宽高,方便通过属性和拖拽进行拉伸
|
|
|
+ width: layer.width + 'px',
|
|
|
+ height: 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: 'pre-wrap',
|
|
|
+ userSelect: 'none'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 双击编辑文字
|
|
|
+const editingTextLayer = ref(null)
|
|
|
+
|
|
|
+const handleLayerDblClick = (e, layer) => {
|
|
|
+ if (layer.type === 'text' && !layer.locked) {
|
|
|
+ editingTextLayer.value = layer
|
|
|
+ // 创建多行输入框进行编辑
|
|
|
+ const textarea = document.createElement('textarea')
|
|
|
+ textarea.value = layer.text
|
|
|
+ textarea.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;
|
|
|
+ min-width: 100px;
|
|
|
+ min-height: 30px;
|
|
|
+ resize: none;
|
|
|
+ z-index: 1000;
|
|
|
+ outline: none;
|
|
|
+ `
|
|
|
+
|
|
|
+ const canvas = canvasRef.value
|
|
|
+ canvas.appendChild(textarea)
|
|
|
+ textarea.focus()
|
|
|
+ textarea.select()
|
|
|
+
|
|
|
+ const saveEdit = () => {
|
|
|
+ layer.text = textarea.value || '双击编辑文字'
|
|
|
+ canvas.removeChild(textarea)
|
|
|
+ editingTextLayer.value = null
|
|
|
+ }
|
|
|
+
|
|
|
+ textarea.addEventListener('blur', 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
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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()
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置拖拽状态
|
|
|
+ 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
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 按 id 删除图层(用于图层卡片更多菜单)
|
|
|
+const deleteLayerById = (id) => {
|
|
|
+ const index = layers.value.findIndex(l => l.id === id)
|
|
|
+ if (index !== -1) {
|
|
|
+ layers.value.splice(index, 1)
|
|
|
+ selectedLayerId.value = selectedLayerId.value === id ? (layers.value.length > 0 ? layers.value[0].id : null) : selectedLayerId.value
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 图层卡片更多菜单:锁定/解锁、删除
|
|
|
+const handleLayerMenuCommand = (cmd, layerId) => {
|
|
|
+ if (cmd === 'delete') deleteLayerById(layerId)
|
|
|
+ else if (cmd === 'lock' || cmd === 'unlock') toggleLayerLock(layerId)
|
|
|
+}
|
|
|
+
|
|
|
+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
|
|
|
+ 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;
|
|
|
+ flex-direction: row;
|
|
|
+ justify-content: flex-start;
|
|
|
+ align-items: stretch;
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ min-width: 0;
|
|
|
+ max-width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 100%;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧栏:与画布同高,固定宽度不收缩 */
|
|
|
+.toolbar {
|
|
|
+ flex: 0 0 370px;
|
|
|
+ width: 370px;
|
|
|
+ min-width: 370px;
|
|
|
+ align-self: stretch;
|
|
|
+ min-height: 100%;
|
|
|
+ background-color: #fff;
|
|
|
+ border-right: 1px solid #ddd;
|
|
|
+ padding: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-body {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧竖条:模版设计 / 文字 / 素材库,图标在上、文字在下 */
|
|
|
+.toolbar-vertical-nav {
|
|
|
+ flex-shrink: 0;
|
|
|
+ width: 56px;
|
|
|
+ background: #f5f6f8;
|
|
|
+ border-right: 1px solid #e8e8e8;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-vertical-nav .nav-item {
|
|
|
+ width: 100%;
|
|
|
+ padding: 10px 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 8px;
|
|
|
+ transition: background 0.2s, color 0.2s;
|
|
|
+ line-height: 1.3;
|
|
|
+ text-align: center;
|
|
|
+ word-break: keep-all;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-vertical-nav .nav-item-icon {
|
|
|
+ font-size: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-vertical-nav .nav-item-text {
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-vertical-nav .nav-item:hover {
|
|
|
+ background: #e8eaef;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-vertical-nav .nav-item.active {
|
|
|
+ background: #ecf5ff;
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+/* 右侧内容区:可滚动区固定占位 + 底部「生成模版」固定不随内容移动 */
|
|
|
+.toolbar-right {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 可滚动区固定高度,切换 tab 时画布区域尺寸不变、不跳动 */
|
|
|
+.toolbar-tabs-content {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 每个 tab 内容同高、内部滚动,避免切换时布局跳动 */
|
|
|
+.toolbar-pane {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-pane-scroll {
|
|
|
+ overflow-y: auto;
|
|
|
+ overflow-x: hidden;
|
|
|
+ padding: 10px 12px;
|
|
|
+ -webkit-overflow-scrolling: touch;
|
|
|
+}
|
|
|
+
|
|
|
+/* 生成模版按钮固定贴底,不随左侧内容滚动 */
|
|
|
+.toolbar-action-footer {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ padding: 12px 12px 14px;
|
|
|
+ background: #fff;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-action-footer .save-template-btn {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-action-footer .save-template-tip {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 6px;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+
|
|
|
+/* AI 工具面板 */
|
|
|
+.ai-tools-pane {
|
|
|
+ padding: 12px 0;
|
|
|
+}
|
|
|
+.ai-tools-section {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+.ai-tools-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ padding: 0 4px;
|
|
|
+}
|
|
|
+.ai-tool-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ border-radius: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+}
|
|
|
+.ai-tool-item:hover {
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+.ai-tool-icon {
|
|
|
+ flex-shrink: 0;
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+.ai-tool-icon-inner {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+.ai-icon-cutout { background: #79bbff; }
|
|
|
+.ai-icon-hd { background: #409eff; }
|
|
|
+.ai-icon-expand { background: #67c23a; }
|
|
|
+.ai-icon-generate { background: #67c23a; }
|
|
|
+.ai-tool-body {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.ai-tool-name {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ line-height: 1.3;
|
|
|
+}
|
|
|
+.ai-tool-desc {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 4px;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+/* AI 未选图片提示弹窗 */
|
|
|
+.ai-prompt-dialog :deep(.el-dialog__header) {
|
|
|
+ padding: 16px 16px 0;
|
|
|
+}
|
|
|
+.ai-prompt-dialog :deep(.el-dialog__body) {
|
|
|
+ padding: 8px 24px 24px;
|
|
|
+}
|
|
|
+.ai-prompt-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.ai-prompt-icon-wrap {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #ecf5ff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.ai-prompt-icon {
|
|
|
+ font-size: 24px;
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+.ai-prompt-text {
|
|
|
+ margin: 0 0 20px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #606266;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.ai-prompt-btn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 完整素材库样式 */
|
|
|
+.materials-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-search :deep(.el-input__wrapper) {
|
|
|
+ border-radius: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-type-chips {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-chip {
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 14px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ background: #f3f4f6;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ line-height: 1;
|
|
|
+ transition: all 0.15s;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-chip:hover {
|
|
|
+ background: #eef2f7;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-chip.active {
|
|
|
+ background: #ecf5ff;
|
|
|
+ border-color: #b3d8ff;
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-groups {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-group-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 2px 2px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-group-title {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #303133;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-more {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ padding: 0;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-more:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-detail-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 2px 2px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-back {
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: none;
|
|
|
+ background: #f3f4f6;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-back:hover {
|
|
|
+ background: #eef2f7;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-detail-title {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.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;
|
|
|
+}
|
|
|
+
|
|
|
+.materials-list-preview {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
+ gap: 6px;
|
|
|
+ padding: 4px;
|
|
|
+ background-color: #f9f9f9;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.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;
|
|
|
+}
|
|
|
+
|
|
|
+/* 操作步骤指引:进来就知道怎么做 */
|
|
|
+.step-guide {
|
|
|
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
|
|
+ border: 1px solid #bae6fd;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.step-guide-title {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #0369a1;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.step-guide-steps {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+.step-item {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #0c4a6e;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+.step-num {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #0284c7;
|
|
|
+ color: #fff;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 600;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.save-template-wrap {
|
|
|
+ flex-shrink: 0;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.save-template-wrap .save-template-btn {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+.save-template-tip {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 6px;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧属性区:三个区块平铺,结构清晰(参考示例项目) */
|
|
|
+.design-sections {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.design-section {
|
|
|
+ background: #fafafa;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 10px 10px 12px;
|
|
|
+ border: 1px solid #eee;
|
|
|
+}
|
|
|
+/* 文字标签页:点击添加文本 + 标题/副标题/正文,虚线框样式 */
|
|
|
+.text-tab-pane {
|
|
|
+ padding: 16px 12px;
|
|
|
+}
|
|
|
+.text-add-heading {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+ margin: 0 0 14px 0;
|
|
|
+}
|
|
|
+.text-add-btns {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.text-add-btn {
|
|
|
+ flex: 1;
|
|
|
+ padding: 10px 12px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #303133;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px dashed #dcdfe6;
|
|
|
+ border-radius: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s, border-color 0.2s;
|
|
|
+}
|
|
|
+.text-add-btn:hover {
|
|
|
+ background: #fafafa;
|
|
|
+ border-color: #c0c4cc;
|
|
|
+}
|
|
|
+.text-add-btn-title {
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.design-section-title {
|
|
|
+ margin: 0 0 8px 0;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ padding-bottom: 6px;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+.layer-info-empty {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 8px 0;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.property-item {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+.property-item:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+}
|
|
|
+.property-item span {
|
|
|
+ width: 60px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 返回按钮区 + 模版命名:固定在左侧栏顶部,预留顶部间距 */
|
|
|
+.design-toolbar {
|
|
|
+ flex-shrink: 0;
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ z-index: 10;
|
|
|
+ margin: 0 -12px 0px -12px;
|
|
|
+ padding: 20px 12px 12px;
|
|
|
+ background: #fff;
|
|
|
+ border-bottom: 1px solid #e5e5e5;
|
|
|
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.design-toolbar .design-toolbar-name-wrap {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.back-to-records {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 6px 10px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #409eff;
|
|
|
+ background: #ecf5ff;
|
|
|
+ border: 1px solid #b3d8ff;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 6px;
|
|
|
+ transition: background 0.2s, border-color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.back-to-records:hover {
|
|
|
+ background: #d9ecff;
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.design-toolbar-name-wrap {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+.design-toolbar-name-edit {
|
|
|
+ min-width: 100px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 240px;
|
|
|
+ padding: 4px 10px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ outline: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: auto;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+.design-toolbar-name-edit:empty::before {
|
|
|
+ content: attr(data-placeholder);
|
|
|
+ color: #c0c4cc;
|
|
|
+}
|
|
|
+.design-toolbar-name-edit:hover {
|
|
|
+ background: #f5f6f8;
|
|
|
+}
|
|
|
+.design-toolbar-name-edit:focus {
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 0 0 1px #409eff;
|
|
|
+}
|
|
|
+.design-toolbar-name-icon {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #909399;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.design-toolbar-name-icon:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.design-toolbar-name-input .el-input__wrapper {
|
|
|
+ padding: 4px 10px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 模板库样式 */
|
|
|
+.template-library {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.library-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.library-header h3 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.template-list {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.template-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ padding: 8px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.template-item {
|
|
|
+ background-color: #f9f9f9;
|
|
|
+ border: 1px solid #e5e5e5;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ transition: all 0.2s;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.template-item:hover {
|
|
|
+ border-color: #409eff;
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.template-preview {
|
|
|
+ height: 120px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.template-preview img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+
|
|
|
+.template-info {
|
|
|
+ padding: 8px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.template-name {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #333;
|
|
|
+ font-weight: 500;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+}
|
|
|
+
|
|
|
+.template-info .el-button {
|
|
|
+ width: 100%;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 4px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 中间区域:可伸缩,不把左右两侧挤出视口 */
|
|
|
+.content-area {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.content-area:has(.template-library-view) {
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+/* 模板库视图 */
|
|
|
+.template-library-view {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ padding: 20px 20px 24px 16px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ background: linear-gradient(180deg, #f0f2f5 0%, #e8eaef 100%);
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .library-header {
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 20px;
|
|
|
+ padding: 16px 20px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
|
+ border: 1px solid rgba(0, 0, 0, 0.04);
|
|
|
+}
|
|
|
+
|
|
|
+.library-header-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.library-title {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1a1a2e;
|
|
|
+ letter-spacing: 0.02em;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-start-design {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 8px 16px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #303133;
|
|
|
+ background: #f5f5f5;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s, border-color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-start-design:hover {
|
|
|
+ background: #ebebeb;
|
|
|
+ border-color: #d4d7de;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-start-design-icon {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 1px solid #606266;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-start-design-text {
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-start-design-large {
|
|
|
+ padding: 12px 24px;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-start-design-large .btn-start-design-icon {
|
|
|
+ width: 28px;
|
|
|
+ height: 28px;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.library-header-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.library-header-actions .library-search {
|
|
|
+ width: 320px;
|
|
|
+}
|
|
|
+
|
|
|
+.library-header-actions .library-search :deep(.el-input__wrapper) {
|
|
|
+ border-radius: 10px;
|
|
|
+ box-shadow: 0 0 0 1px #e4e7ed;
|
|
|
+}
|
|
|
+
|
|
|
+.library-header-actions .library-search :deep(.el-input__wrapper:hover) {
|
|
|
+ box-shadow: 0 0 0 1px #c0c4cc;
|
|
|
+}
|
|
|
+
|
|
|
+/* 菜单栏:我的作品、更多模版,选中项带下划线 */
|
|
|
+.library-menu {
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: stretch;
|
|
|
+ gap: 0;
|
|
|
+ padding: 0 0 0 4px;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.library-menu-item {
|
|
|
+ padding: 12px 20px 14px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #909399;
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ border-bottom: 3px solid transparent;
|
|
|
+ margin-bottom: -1px;
|
|
|
+ border-radius: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: color 0.2s, border-color 0.2s, font-weight 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.library-menu-item:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.library-menu-item.active {
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 600;
|
|
|
+ border-bottom-color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.template-list-wrap {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow-y: auto;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
|
|
+ padding: 20px 20px 20px 16px;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 1px solid rgba(0, 0, 0, 0.04);
|
|
|
+}
|
|
|
+
|
|
|
+.template-list-skeleton {
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.records-empty {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ min-height: 360px;
|
|
|
+ padding: 48px 24px;
|
|
|
+ color: #606266;
|
|
|
+ background: linear-gradient(180deg, #fafbfc 0%, #f5f6f8 100%);
|
|
|
+ border-radius: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.records-empty-icon {
|
|
|
+ color: #c0c4cc;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ opacity: 0.9;
|
|
|
+}
|
|
|
+
|
|
|
+.records-empty-text {
|
|
|
+ margin: 0 0 28px;
|
|
|
+ font-size: 15px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, 200px);
|
|
|
+ justify-content: start;
|
|
|
+ gap: 16px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-item {
|
|
|
+ width: 200px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e8eaed;
|
|
|
+ border-radius: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s;
|
|
|
+ cursor: pointer;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-item:hover {
|
|
|
+ border-color: #c6e2ff;
|
|
|
+ transform: translateY(-3px);
|
|
|
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
|
|
|
+}
|
|
|
+
|
|
|
+/* 统一预览比例 4:4;图片完整显示、居中,不裁剪 */
|
|
|
+.template-library-view .template-preview {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+ aspect-ratio: 4 / 4;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f0f2f5;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-preview img {
|
|
|
+ position: absolute;
|
|
|
+ left: 50%;
|
|
|
+ top: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ max-width: 100%;
|
|
|
+ max-height: 100%;
|
|
|
+ width: auto;
|
|
|
+ height: auto;
|
|
|
+ object-fit: contain;
|
|
|
+ object-position: center center;
|
|
|
+ transition: transform 0.25s ease;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-release-tag {
|
|
|
+ position: absolute;
|
|
|
+ top: 8px;
|
|
|
+ right: 8px;
|
|
|
+ padding: 2px 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #fff;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-release-tag.published {
|
|
|
+ background: rgba(103, 194, 58, 0.85);
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-item:hover .template-preview img {
|
|
|
+ transform: translate(-50%, -50%) scale(1.02);
|
|
|
+}
|
|
|
+
|
|
|
+/* 图片下方白底操作栏:模版名 + 发布、继续编辑 + 右侧三点 */
|
|
|
+.template-library-view .template-card-footer {
|
|
|
+ padding: 12px 14px;
|
|
|
+ background: #fafbfc;
|
|
|
+ border-top: 1px solid #eee;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-name {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #303133;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ line-height: 1.3;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link {
|
|
|
+ font-size: 13px;
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 0;
|
|
|
+ transition: opacity 0.2s, color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-publish {
|
|
|
+ color: #f56c6c;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-publish:hover {
|
|
|
+ color: #f78989;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-publish.disabled,
|
|
|
+.template-library-view .template-card-link-publish:disabled {
|
|
|
+ color: #909399;
|
|
|
+ cursor: default;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-publish.disabled:hover,
|
|
|
+.template-library-view .template-card-link-publish:disabled:hover {
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-edit {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-edit:hover {
|
|
|
+ color: #66b1ff;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-same {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-link-same:hover {
|
|
|
+ color: #66b1ff;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-dots {
|
|
|
+ color: #909399;
|
|
|
+ padding: 2px 4px;
|
|
|
+ margin-left: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-dots:hover {
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.template-library-view .template-card-more {
|
|
|
+ margin-left: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.template-meta {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+
|
|
|
+.template-style, .template-type {
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ padding: 2px 8px;
|
|
|
+ border-radius: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.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 1 0;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background-color: #e8e8e8;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.canvas-wrapper {
|
|
|
+ position: relative;
|
|
|
+ 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;
|
|
|
+ transition: box-shadow 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.layer.selected {
|
|
|
+ box-shadow: 0 0 0 2px #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 {
|
|
|
+ flex: 0 0 320px;
|
|
|
+ width: 320px;
|
|
|
+ min-width: 320px;
|
|
|
+ background: #f5f6f8;
|
|
|
+ border-left: 1px solid #e8e8e8;
|
|
|
+ padding: 12px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.right-panel-menu {
|
|
|
+ display: flex;
|
|
|
+ gap: 0;
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ background: #e8eaef;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 3px;
|
|
|
+}
|
|
|
+.right-panel-menu-item {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s, color 0.2s;
|
|
|
+}
|
|
|
+.right-panel-menu-item:hover {
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+.right-panel-menu-item.active {
|
|
|
+ background: #fff;
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 600;
|
|
|
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+
|
|
|
+.right-section {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ min-height: 0;
|
|
|
+ flex: 1 1 0;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.right-section-props {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding-right: 4px;
|
|
|
+}
|
|
|
+.props-block {
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.props-block:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+}
|
|
|
+.props-block-title {
|
|
|
+ margin: 0 0 10px 0;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+.right-section-props .property-item {
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.right-section-props .property-item:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+}
|
|
|
+.right-section-props .layer-info-empty {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ padding: 8px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 6px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-list {
|
|
|
+ flex: 1;
|
|
|
+ margin-top: 4px;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+/* 卡片式图层项:拖拽手柄 | 缩略图 | 名称 | 可见性 | 更多 */
|
|
|
+.layer-item-card {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 8px 10px;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+ gap: 8px;
|
|
|
+ user-select: none;
|
|
|
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card:hover {
|
|
|
+ border-color: #c0c4cc;
|
|
|
+ background: #fafafa;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card.selected {
|
|
|
+ background: #ecf5ff;
|
|
|
+ border-color: #409eff;
|
|
|
+ box-shadow: 0 0 0 1px #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card.dragging {
|
|
|
+ opacity: 0.6;
|
|
|
+ cursor: grabbing;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card.drag-over {
|
|
|
+ background: #d9ecff;
|
|
|
+ border: 2px dashed #409eff;
|
|
|
+ transform: scale(1.02);
|
|
|
+}
|
|
|
+
|
|
|
+.layer-drag-handle {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #909399;
|
|
|
+ cursor: grab;
|
|
|
+ transition: color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-drag-handle:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card.dragging .layer-drag-handle {
|
|
|
+ cursor: grabbing;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-card-thumb {
|
|
|
+ flex-shrink: 0;
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ border-radius: 6px;
|
|
|
+ object-fit: cover;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-card-thumb.text-thumb {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #fff;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-card-thumb.img-placeholder {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #c0c4cc;
|
|
|
+ font-size: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-card-name {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-card-action {
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #606266;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 2px;
|
|
|
+ transition: color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-card-action:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card.locked {
|
|
|
+ opacity: 0.85;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-item-card.locked .layer-drag-handle {
|
|
|
+ cursor: not-allowed;
|
|
|
+ color: #c0c4cc;
|
|
|
+}
|
|
|
+
|
|
|
+.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,
|
|
|
+.design-sections::-webkit-scrollbar {
|
|
|
+ width: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-list::-webkit-scrollbar-thumb,
|
|
|
+.layer-info::-webkit-scrollbar-thumb,
|
|
|
+.design-sections::-webkit-scrollbar-thumb {
|
|
|
+ background-color: #ddd;
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.layer-list::-webkit-scrollbar-track,
|
|
|
+.layer-info::-webkit-scrollbar-track,
|
|
|
+.design-sections::-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;
|
|
|
+ box-sizing: border-box !important;
|
|
|
+ height: 100vh !important;
|
|
|
+ max-height: 100vh !important;
|
|
|
+ overflow: hidden !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>
|