liuhairui 4 天之前
父节点
当前提交
6342dd786b

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

@@ -1462,4 +1462,40 @@ export const Material_Upload = (data) => {
     method: 'post',
     data
   })
+}
+
+// 素材分类查询
+export const Material_Category_List = (params) => {
+  return service({
+    url: '/mes_server/Material/Material_Category_List',
+    method: 'get',
+    params
+  })
+}
+
+// 新增素材分类
+export const Material_Category_Add = (data) => {
+  return service({
+    url: '/mes_server/Material/Material_Category_Add',
+    method: 'post',
+    data
+  })
+}
+
+// 修改素材分类
+export const Material_Category_Update = (data) => {
+  return service({
+    url: '/mes_server/Material/Material_Category_Update',
+    method: 'post',
+    data
+  })
+}
+
+// 删除素材分类
+export const Material_Category_Delete = (data) => {
+  return service({
+    url: '/mes_server/Material/Material_Category_Delete',
+    method: 'post',
+    data
+  })
 }

+ 55 - 0
src/view/TemplateManagement/ARCHITECTURE.md

@@ -0,0 +1,55 @@
+# CreateTemplate 模版设计页 - 架构与维护指南
+
+## 概述
+
+CreateTemplate.vue 是模版创建/编辑页,功能较多,代码量大。本文档描述当前架构、目录结构与维护建议,便于团队协作与调试。
+
+## 目录结构
+
+```
+TemplateManagement/
+├── CreateTemplate.vue      # 主页面(编排层)
+├── TemplateDesign.vue     # 模版列表页
+├── constants.js           # 常量集中管理(可调试、易维护)
+├── utils.js               # 纯工具函数
+├── ARCHITECTURE.md        # 本架构文档
+└── composables/           # (规划)可复用的组合式逻辑
+    └── ...
+```
+
+## 模块职责
+
+### constants.js
+- **职责**:集中管理配置常量,便于调整与调试
+- **内容**:画布比例、AI 工具列表、素材分类、预设参数等
+- **使用**:`import { RATIO_CONFIG, AI_TOOL_LIST, ... } from './constants.js'`
+
+### utils.js
+- **职责**:纯函数工具,无副作用,可单独测试
+- **内容**:`resolveMaterialUrl`、`toStoragePath`、`formatFileSize`、`getImageLoadUrl`
+- **使用**:`import { resolveMaterialUrl, getImageLoadUrl, toStoragePath, formatFileSize } from './utils.js'`
+
+### CreateTemplate.vue
+- **职责**:页面编排、状态管理、事件协调
+- **建议**:保持为“薄编排层”,将复杂逻辑逐步抽离到 composables 或子组件
+
+## 可维护性建议
+
+1. **常量修改**:统一在 `constants.js` 中修改,避免在组件内硬编码
+2. **工具函数**:新增纯函数放入 `utils.js`,并补充 JSDoc
+3. **调试**:通过修改 `constants.js` 中的值可快速验证不同配置
+4. **后续拆分**:可将 `useMaterials`、`useCanvasLayers`、`useTemplateSave` 等抽成 composables
+
+## 状态流转概要
+
+- **pages / layers**:多页模版,每页独立图层数组
+- **currentPageIndex**:当前编辑页
+- **selectedLayerId**:当前选中图层
+- **editingTemplateId**:编辑已有模版时的 ID(保存走更新接口)
+
+## 关键流程
+
+- 新建:`clearDesign()` → 空画布
+- 编辑:`useTemplate(template)` → 加载模版数据
+- 做同款:`useAsTemplate(template)` → 加载但不设 editingTemplateId
+- 保存:`saveTemplate()` → 根据 editingTemplateId 调用新增/更新接口

文件差异内容过多而无法显示
+ 343 - 436
src/view/TemplateManagement/CreateTemplate.vue


+ 169 - 26
src/view/TemplateManagement/TemplateDesign.vue

@@ -14,17 +14,17 @@
               <span class="btn-start-design-text">创建模版</span>
             </button>
           </div>
-          <div class="library-header-actions">
+          <div class="library-header-actions library-search-wrap">
             <el-input
-              v-if="templates.length > 0"
               v-model="templateSearch"
               size="default"
               clearable
               :prefix-icon="Search"
               placeholder="搜索模板"
-              @input="handleTemplateSearch"
               class="library-search"
+              @keyup.enter="doSearch"
             />
+            <el-button type="primary" @click="doSearch">搜索模版</el-button>
           </div>
         </header>
 
@@ -35,7 +35,7 @@
             :class="{ active: libraryMenuActive === 'myWorks' }"
             @click="switchLibraryMenu('myWorks')"
           >
-            我的模版
+            我的设计
           </button>
           <button
             type="button"
@@ -43,42 +43,74 @@
             :class="{ active: libraryMenuActive === 'moreTemplates' }"
             @click="switchLibraryMenu('moreTemplates')"
           >
-            更多模版
+            模版社区
+          </button>
+        </nav>
+
+        <!-- 我的设计中:按发布状态筛选:全部 / 已发布 / 未发布 -->
+        <nav v-if="libraryMenuActive === 'myWorks'" class="publish-filter-bar">
+          <button
+            type="button"
+            class="publish-filter-item"
+            :class="{ active: publishFilter === 'all' }"
+            @click="publishFilter = 'all'"
+          >
+            全部
+          </button>
+          <button
+            type="button"
+            class="publish-filter-item"
+            :class="{ active: publishFilter === 'published' }"
+            @click="publishFilter = 'published'"
+          >
+            已发布
+          </button>
+          <button
+            type="button"
+            class="publish-filter-item"
+            :class="{ active: publishFilter === 'unpublished' }"
+            @click="publishFilter = 'unpublished'"
+          >
+            未发布
           </button>
         </nav>
 
         <section class="template-list-wrap">
           <el-skeleton v-if="templatesLoading" :rows="8" animated class="template-list-skeleton" />
-          <div v-else-if="templates.length === 0" class="records-empty">
+          <div v-else-if="filteredTemplates.length === 0" class="records-empty">
             <div class="records-empty-icon">
               <el-icon :size="72"><Picture /></el-icon>
             </div>
-            <p class="records-empty-text">暂无记录,先去创建设计吧~</p>
-            <button type="button" class="btn-start-design btn-start-design-large" @click="startNewDesign">
+            <p class="records-empty-text">{{ templates.length === 0 ? '暂无记录,先去创建设计吧~' : '暂无符合条件的模版' }}</p>
+            <button v-if="templates.length === 0" type="button" class="btn-start-design btn-start-design-large" @click="startNewDesign">
               <span class="btn-start-design-icon"><el-icon><Plus /></el-icon></span>
               <span class="btn-start-design-text">开始设计</span>
             </button>
           </div>
-          <div v-else class="template-grid">
-            <div
-              v-for="template in templates"
-              :key="template.id"
-              class="template-item"
-            >
-              <div class="template-preview">
+          <div v-else class="template-grid-wrap">
+            <div class="template-grid">
+              <div
+                v-for="template in displayedTemplates"
+                :key="template.id"
+                class="template-item"
+              >
+                <div class="template-preview" @click="openPreview(template)">
                 <img
                   :src="formatImageUrl(template.thumbnail_image || template.template_image_url)"
                   :alt="template.template_name"
                   loading="lazy"
+                  class="template-preview-img"
                   @error="(e) => { e.target.onerror = null; e.target.src = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22150%22%3E%3Crect fill=%22%23f5f5f5%22 width=%22200%22 height=%22150%22/%3E%3C/svg%3E' }"
                 />
+                <div class="template-preview-zoom">
+                  <el-icon :size="20"><ZoomIn /></el-icon>
+                </div>
               <span v-if="libraryMenuActive === 'myWorks'" class="template-release-tag" :class="{ published: isPublished(template) }">
                   {{ isPublished(template) ? '已发布' : '未发布' }}
                 </span>
               </div>
               <div class="template-card-footer">
                 <span class="template-card-name" :title="template.template_name">{{ template.template_name || '未命名模版' }}</span>
-                <span v-if="template.sys_id" class="template-card-author" :title="template.sys_id">创建者:{{ template.sys_id }}</span>
                 <div class="template-card-actions">
                   <template v-if="libraryMenuActive === 'myWorks'">
                     <button type="button" class="template-card-link template-card-link-publish" :class="{ disabled: publishLoading === template.id }" :disabled="publishLoading === template.id" @click.stop="isPublished(template) ? handleUnpublish(template) : handlePublish(template)">{{ isPublished(template) ? '取消发布' : '发布' }}</button>
@@ -99,10 +131,30 @@
                     <button type="button" class="template-card-link template-card-link-same" @click.stop="goUseAsTemplate(template)">一键同款</button>
                   </template>
                 </div>
+                </div>
               </div>
             </div>
+            <div class="gva-pagination">
+              <el-pagination
+                v-if="total > 0"
+                :current-page="page"
+                :page-size="pageSize"
+                :page-sizes="[10, 30, 50, 100]"
+                :total="total"
+                layout="total, sizes, prev, pager, next, jumper"
+                @current-change="handlePageChange"
+                @size-change="handleSizeChange"
+              />
+            </div>
           </div>
         </section>
+        <!-- 模版图片放大预览 -->
+        <el-image-viewer
+          v-if="previewVisible"
+          :url-list="previewImageUrl ? [previewImageUrl] : []"
+          :hide-on-click-modal="true"
+          @close="previewVisible = false"
+        />
       </div>
     </template>
     <!-- 设计页:参数由列表页带入 -->
@@ -121,7 +173,7 @@
 import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
 import { useRoute } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { Plus, Picture, Search, MoreFilled, Loading, Delete } from '@element-plus/icons-vue'
+import { Plus, Picture, Search, MoreFilled, Loading, Delete, ZoomIn } from '@element-plus/icons-vue'
 import { ElMessageBox } from 'element-plus'
 import { useUserStore } from '@/pinia/modules/user'
 import { Template_Material_Delete, Template_Material_Publish, Template_Material_Unpublish, product_template } from '@/api/mes/job'
@@ -132,8 +184,14 @@ const templates = ref([])
 const templatesLoading = ref(false)
 const templateSearch = ref('')
 const libraryMenuActive = ref('myWorks')
+const publishFilter = ref('all') // 'all' | 'published' | 'unpublished',仅在「我的设计」中生效
+const page = ref(1)
+const pageSize = ref(100) // 每页 100 条
+const total = ref(0)
 const publishLoading = ref(null)
 const deleteLoading = ref(null)
+const previewVisible = ref(false)
+const previewImageUrl = ref('')
 const editTemplate = ref(null)
 const editMode = ref('create')
 // 用于强制 CreateTemplate 在切换不同模版/模式时重新初始化
@@ -144,6 +202,19 @@ const route = useRoute()
 
 const isPublished = (template) => template?.release === 1 || template?.release === '1'
 
+// 按发布状态筛选后的列表(仅在「我的设计」时生效)
+const filteredTemplates = computed(() => {
+  if (libraryMenuActive.value !== 'myWorks') return templates.value
+  if (publishFilter.value === 'all') return templates.value
+  return templates.value.filter((t) => {
+    const pub = isPublished(t)
+    return publishFilter.value === 'published' ? pub : !pub
+  })
+})
+
+// 用于展示的列表(后端分页,直接使用筛选后的当前页数据)
+const displayedTemplates = computed(() => filteredTemplates.value)
+
 // 图片 URL:拼接 VITE_BASE_PATH + VITE_UPLOADS_PORT(如 http://IP:端口/uploads/xxx)
 const formatImageUrl = (path) => {
   if (!path || typeof path !== 'string') return ''
@@ -168,13 +239,22 @@ const formatImageUrl = (path) => {
 const fetchTemplates = async () => {
   try {
     templatesLoading.value = true
-    const params = libraryMenuActive.value === 'myWorks' && userStore.userInfo?.nickName
-      ? { sys_id: userStore.userInfo.nickName }
-      : {}
+    const params = {
+      page: page.value,
+      limit: pageSize.value
+    }
+    if (libraryMenuActive.value === 'myWorks' && userStore.userInfo?.nickName) {
+      params.sys_id = userStore.userInfo.nickName
+    }
+    const searchVal = (templateSearch.value || '').trim()
+    if (searchVal) params.search = searchVal
     const response = await product_template(params)
     const data = response
     if (data.code === 0) {
-      templates.value = Array.isArray(data.data) ? data.data : (data.data || [])
+      const payload = data.data
+      const list = Array.isArray(payload?.list) ? payload.list : (Array.isArray(payload) ? payload : [])
+      templates.value = list
+      total.value = Number(payload?.total ?? data.total ?? list.length) || 0
     } else {
       ElMessage.error(data?.msg || '获取模板库失败')
     }
@@ -186,13 +266,36 @@ const fetchTemplates = async () => {
   }
 }
 
+const handlePageChange = (p) => {
+  page.value = p
+  fetchTemplates()
+}
+
+const handleSizeChange = (size) => {
+  pageSize.value = size
+  page.value = 1
+  fetchTemplates()
+}
+
 const switchLibraryMenu = (tab) => {
   if (libraryMenuActive.value === tab) return
   libraryMenuActive.value = tab
+  page.value = 1
+  fetchTemplates()
+}
+
+const doSearch = () => {
+  page.value = 1
   fetchTemplates()
 }
 
-const handleTemplateSearch = () => {}
+const openPreview = (template) => {
+  const url = formatImageUrl(template.thumbnail_image || template.template_image_url)
+  if (url) {
+    previewImageUrl.value = url
+    previewVisible.value = true
+  }
+}
 
 const startNewDesign = () => {
   editTemplate.value = null
@@ -368,6 +471,7 @@ onBeforeUnmount(() => {
 }
 .library-header-left { display: flex; align-items: center; }
 .library-header-actions { display: flex; align-items: center; gap: 12px; }
+.library-search-wrap { display: flex; align-items: center; gap: 8px; }
 .btn-start-design {
   display: inline-flex;
   align-items: center;
@@ -401,12 +505,43 @@ onBeforeUnmount(() => {
   margin-bottom: -1px;
 }
 .library-menu-item.active { color: #667eea; font-weight: 600; border-bottom-color: #667eea; }
-.template-list-wrap { flex: 1; padding: 16px; overflow: auto; }
+.publish-filter-bar {
+  display: flex;
+  gap: 0;
+  padding: 8px 16px 0;
+  background: #fff;
+  border-bottom: 1px solid #eee;
+}
+.publish-filter-item {
+  padding: 8px 16px;
+  border: none;
+  background: none;
+  font-size: 13px;
+  color: #909399;
+  cursor: pointer;
+  border-radius: 6px;
+  transition: background 0.2s, color 0.2s;
+}
+.publish-filter-item:hover { color: #606266; background: #f5f7fa; }
+.publish-filter-item.active { color: #667eea; font-weight: 500; background: #f0f4ff; }
+.template-list-wrap { flex: 1; padding: 12px 8px 16px; overflow: auto; }
 .template-list-skeleton { padding: 16px; }
+.template-grid-wrap { display: flex; flex-direction: column; gap: 12px; }
 .template-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
-  gap: 16px;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 12px;
+}
+@media (max-width: 1600px) { .template-grid { grid-template-columns: repeat(5, 1fr); } }
+@media (max-width: 1400px) { .template-grid { grid-template-columns: repeat(5, 1fr); } }
+@media (max-width: 1100px) { .template-grid { grid-template-columns: repeat(4, 1fr); } }
+@media (max-width: 900px) { .template-grid { grid-template-columns: repeat(3, 1fr); } }
+@media (max-width: 768px) { .template-grid { grid-template-columns: repeat(2, 1fr); } }
+.load-more-sentinel {
+  height: 20px; min-height: 20px; pointer-events: none;
+}
+.load-more-tip {
+  text-align: center; padding: 12px; font-size: 13px; color: #909399;
 }
 .template-item {
   background: #fff;
@@ -423,7 +558,15 @@ onBeforeUnmount(() => {
   transform: translateY(0);
   box-shadow: 0 2px 6px rgba(0,0,0,0.1);
 }
-.template-preview { position: relative; aspect-ratio: 4/3; cursor: default; background: #f5f5f5; }
+.template-preview { position: relative; aspect-ratio: 4/3.5; cursor: pointer; background: #f5f5f5; }
+.template-preview-zoom {
+  position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px;
+  border-radius: 50%; background: rgba(0,0,0,0.5); color: #fff;
+  display: flex; align-items: center; justify-content: center; pointer-events: none;
+  opacity: 0; transition: opacity 0.2s ease;
+}
+.template-preview:hover .template-preview-zoom,
+.template-preview:active .template-preview-zoom { opacity: 1; }
 .template-preview img { width: 100%; height: 100%; object-fit: contain; display: block; }
 .template-release-tag {
   position: absolute; top: 8px; right: 8px;

+ 192 - 0
src/view/TemplateManagement/components/AddTabPane.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="toolbar-pane toolbar-pane-scroll add-tab-pane">
+    <!-- 文字更多详情 -->
+    <div v-if="addTextViewMode === 'more'" class="materials-detail add-text-more-detail">
+      <div class="materials-detail-header">
+        <button type="button" class="materials-back" @click="backTextAddList">
+          <el-icon><ArrowLeft /></el-icon>
+        </button>
+        <span class="materials-detail-title">文字</span>
+      </div>
+      <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 text-add-btn-subtitle" @click="addTextLayer('subtitle')">副标题</button>
+        <button type="button" class="text-add-btn text-add-btn-body" @click="addTextLayer('body')">正文</button>
+      </div>
+    </div>
+    <div v-else-if="addShapeViewMode === 'more'" class="materials-detail add-shape-more-detail">
+      <div class="materials-detail-header">
+        <button type="button" class="materials-back" @click="backShapeAddList">
+          <el-icon><ArrowLeft /></el-icon>
+        </button>
+        <span class="materials-detail-title">形状与线条</span>
+      </div>
+      <div class="add-shape-btns add-shape-btns-more">
+        <button type="button" class="add-shape-btn" @click="addShapeLayer('rect')">矩形</button>
+        <button type="button" class="add-shape-btn" @click="addShapeLayer('circle')">圆形</button>
+        <button type="button" class="add-shape-btn" @click="addShapeLayer('ellipse')">椭圆</button>
+        <button type="button" class="add-shape-btn" @click="addShapeLayer('line')">直线</button>
+      </div>
+    </div>
+
+    <template v-else>
+      <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
+        ref="uploadRef"
+        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/jpg/webp,大小不超过 {{ maxImageSizeMb }}MB
+        </div>
+      </el-upload>
+
+      <div class="add-text-section">
+        <div class="text-add-header">
+          <span class="text-add-heading">文字</span>
+          <button type="button" class="materials-more" @click="openTextAddMore">
+            更多 &gt;
+          </button>
+        </div>
+        <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 text-add-btn-subtitle" @click="addTextLayer('subtitle')">副标题</button>
+          <button type="button" class="text-add-btn text-add-btn-body" @click="addTextLayer('body')">正文</button>
+        </div>
+      </div>
+
+      <div class="add-shape-section">
+        <div class="text-add-header">
+          <span class="text-add-heading">形状与线条</span>
+          <button type="button" class="materials-more" @click="openShapeAddMore">
+            更多 &gt;
+          </button>
+        </div>
+        <div class="add-shape-btns">
+          <button type="button" class="add-shape-btn" @click="addShapeLayer('rect')">矩形</button>
+          <button type="button" class="add-shape-btn" @click="addShapeLayer('circle')">圆形</button>
+          <button type="button" class="add-shape-btn" @click="addShapeLayer('ellipse')">椭圆</button>
+          <button type="button" class="add-shape-btn" @click="addShapeLayer('line')">直线</button>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { ArrowLeft, Upload } from '@element-plus/icons-vue'
+
+const props = defineProps({
+  addTextViewMode: { type: String, default: 'list' },
+  addShapeViewMode: { type: String, default: 'list' },
+  addTextLayer: { type: Function, required: true },
+  addShapeLayer: { type: Function, required: true },
+  backTextAddList: { type: Function, required: true },
+  backShapeAddList: { type: Function, required: true },
+  openTextAddMore: { type: Function, required: true },
+  openShapeAddMore: { type: Function, required: true },
+  beforeUpload: { type: Function, required: true },
+  handleUpload: { type: Function, required: true },
+  maxImageSizeMb: { type: Number, default: 1 }
+})
+
+const uploadRef = ref(null)
+
+defineExpose({ uploadRef })
+</script>
+
+<style scoped lang="scss">
+.add-tab-pane { padding: 12px 20px; }
+
+.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;
+}
+
+.custom-upload { width: 100%; margin-bottom: 8px; }
+.custom-upload :deep(.el-upload-dragger) {
+  padding: 20px 16px; background-color: #fff; 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: #fff; 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: #333; }
+.custom-upload :deep(.el-upload__text) { margin: 0; font-size: 14px; color: #333; }
+.custom-upload :deep(.el-upload__tip) { font-size: 12px !important; color: #888 !important; text-align: center; line-height: 1.4; }
+
+.add-text-section { margin-top: 10px; }
+.text-add-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
+.text-add-heading { font-size: 12px; font-weight: 600; color: #303133; margin: 0; }
+.text-add-btns { display: flex; gap: 10px; }
+.text-add-btns-more { margin-top: 10px; }
+.text-add-btn {
+  flex: 1; padding: 10px 12px; font-size: 14px; color: #303133;
+  background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 8px;
+  cursor: pointer; transition: background 0.2s, border-color 0.2s;
+}
+.text-add-btn:hover { background: #eef1f6; border-color: #c0c4cc; }
+.text-add-btn-title { font-size: 18px; font-weight: 700; }
+.text-add-btn-subtitle { font-size: 15px; font-weight: 500; }
+.text-add-btn-body { font-size: 14px; font-weight: 400; }
+
+.add-shape-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid #ebeef5; }
+.add-shape-section .text-add-heading { margin-bottom: 8px; }
+.add-shape-btns { display: flex; flex-wrap: wrap; gap: 8px; }
+.add-shape-btns-more { margin-top: 10px; }
+.add-shape-btn {
+  padding: 8px 14px; font-size: 13px; color: #303133;
+  background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 6px;
+  cursor: pointer; transition: background 0.2s, border-color 0.2s;
+}
+.add-shape-btn:hover { background: #eef1f6; border-color: #c0c4cc; }
+
+.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-more { font-size: 12px; color: #909399; background: transparent; border: none; padding: 0; cursor: pointer; }
+.materials-more:hover { color: #409eff; }
+</style>

+ 89 - 0
src/view/TemplateManagement/components/AiTabPane.vue

@@ -0,0 +1,89 @@
+<template>
+  <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>
+</template>
+
+<script setup>
+import { inject } from 'vue'
+
+defineOptions({ name: 'AiTabPane' })
+
+const aiToolList = inject('aiToolList', [])
+const handleAiToolClick = inject('handleAiToolClick', () => {})
+</script>
+
+<style scoped>
+.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;
+}
+</style>

+ 73 - 0
src/view/TemplateManagement/components/CanvasArea.vue

@@ -0,0 +1,73 @@
+<template>
+  <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',
+            'shape-layer-wrap': layer.type === 'shape'
+          }"
+          :style="getLayerStyle(layer)"
+          @mousedown.stop="handleLayerMouseDown($event, layer)"
+          @dblclick.stop="handleLayerDblClick($event, layer)"
+        >
+          <template v-if="layer.type === 'image' || (layer.type !== 'text' && layer.type !== 'shape' && layer.url)">
+            <img :src="getImageLoadUrl(layer.url)" :alt="layer.name" draggable="false" />
+          </template>
+          <template v-else-if="layer.type === 'shape'">
+            <div class="shape-layer" :style="getShapeStyle(layer)" />
+          </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>
+</template>
+
+<script setup>
+defineProps({
+  canvasRef: Object,
+  canvasAreaRef: Object,
+  canvasWidth: Number,
+  canvasHeight: Number,
+  layers: { type: Array, default: () => [] },
+  selectedLayerId: [String, Number],
+  getLayerStyle: { type: Function, required: true },
+  getShapeStyle: { type: Function, required: true },
+  getTextStyle: { type: Function, required: true },
+  getImageLoadUrl: { type: Function, required: true },
+  handleLayerMouseDown: { type: Function, required: true },
+  handleLayerDblClick: { type: Function, required: true },
+  handleCanvasMouseDown: { type: Function, required: true },
+  handleCanvasMouseMove: { type: Function, required: true },
+  handleCanvasMouseUp: { type: Function, required: true },
+  handleCanvasWheel: { type: Function, required: true },
+  startResize: { type: Function, required: true },
+  startRotate: { type: Function, required: true }
+})
+</script>

+ 361 - 0
src/view/TemplateManagement/components/LayerPanel.vue

@@ -0,0 +1,361 @@
+<template>
+  <div class="layer-panel">
+    <div class="right-panel-menu">
+      <button type="button" class="right-panel-menu-item" :class="{ active: rightPanelTab === 'layer' }" @click="emit('update:rightPanelTab', 'layer')">
+        <el-icon><List /></el-icon>
+        <span>图层管理</span>
+      </button>
+      <button type="button" class="right-panel-menu-item" :class="{ active: rightPanelTab === 'props' }" @click="emit('update: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 && layerNameEditingId !== layer.id"
+          @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-if="layer.type === 'shape'">
+            <div class="layer-card-thumb shape-thumb" :class="{ 'shape-thumb-no-fill': layer.fillMode === 'none' }" :style="{ backgroundColor: layer.fillMode === 'none' ? '#f5f7fa' : (layer.fillColor || '#e0e0e0'), border: `1px solid ${layer.strokeColor || '#606266'}` }">
+              <span class="shape-thumb-icon">{{ layer.shapeType === 'rect' ? '□' : layer.shapeType === 'circle' ? '○' : layer.shapeType === 'ellipse' ? '◯' : '—' }}</span>
+            </div>
+          </template>
+          <template v-else>
+            <img v-if="layer.url" :src="getImageLoadUrl(layer.url)" class="layer-card-thumb" alt="" />
+            <div v-else class="layer-card-thumb img-placeholder"><el-icon><Picture /></el-icon></div>
+          </template>
+          <div class="layer-card-name-wrap" draggable="false" @dragstart.stop>
+            <div class="layer-card-name-trigger">
+              <el-popover
+                :visible="layerNameEditingId === layer.id"
+                @update:visible="() => setLayerNameEditingId(null)"
+                trigger="manual"
+                placement="bottom-start"
+                :width="260"
+                popper-class="layer-name-edit-popover"
+              >
+                <template #reference>
+                  <span
+                    class="layer-card-name"
+                    :title="layer.name || (layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片')"
+                    @click.stop="openLayerNameEdit(layer)"
+                  >{{ layer.name || (layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片') }}</span>
+                </template>
+                <div class="layer-name-edit-inner" @click.stop>
+                  <el-input
+                    :ref="el => setLayerNameInputRef(layer.id, el)"
+                    v-model="layer.name"
+                    size="small"
+                    :placeholder="layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片'"
+                    style="width: 100%; min-width: 200px;"
+                    @blur="setLayerNameEditingId(null)"
+                    @keydown.enter.prevent="setLayerNameEditingId(null)"
+                  />
+                </div>
+              </el-popover>
+              <el-icon class="layer-card-name-edit-icon" title="编辑名称" @click.stop="openLayerNameEdit(layer)"><EditPen /></el-icon>
+              <div v-if="layer.type !== 'text' && layer.fileSize" class="layer-card-size">{{ formatFileSize(layer.fileSize) }}</div>
+            </div>
+          </div>
+          <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 :model-value="canvasRatio" size="small" style="flex: 1;" @update:model-value="$emit('update:canvas-ratio', $event)" @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 :model-value="canvasWidth" size="small" :step="10" :min="100" :max="2000" @update:model-value="$emit('update:canvas-width', $event)" @change="handleCanvasSizeChange" />
+        </div>
+        <div class="property-item">
+          <span>高度</span>
+          <el-input-number :model-value="canvasHeight" size="small" :step="10" :min="100" :max="2000" @update:model-value="$emit('update:canvas-height', $event)" @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-group label="思源字体">
+                <el-option label="思源黑体" value="Source Han Sans SC" />
+                <el-option label="思源黑体 (Noto Sans)" value="Noto Sans SC" />
+                <el-option label="思源宋体" value="Source Han Serif SC" />
+                <el-option label="思源宋体 (Noto Serif)" value="Noto Serif SC" />
+              </el-option-group>
+              <el-option-group label="中文字体">
+                <el-option label="微软雅黑" value="Microsoft YaHei" />
+                <el-option label="黑体" value="SimHei" />
+                <el-option label="宋体" value="SimSun" />
+                <el-option label="楷体" value="KaiTi" />
+                <el-option label="仿宋" value="FangSong" />
+                <el-option label="幼圆" value="YouYuan" />
+                <el-option label="隶书" value="LiSu" />
+                <el-option label="华文黑体" value="STHeiti" />
+                <el-option label="华文楷体" value="STKaiti" />
+                <el-option label="华文仿宋" value="STFangsong" />
+                <el-option label="苹方" value="PingFang SC" />
+                <el-option label="阿里妈妈数黑体" value="AlibabaPuHuiTi" />
+                <el-option label="优设标题黑" value="YouSheBiaoTiHei" />
+                <el-option label="灵悦黑体" value="LingYueHeiTi" />
+              </el-option-group>
+              <el-option-group label="英文字体">
+                <el-option label="Arial" value="Arial" />
+                <el-option label="Helvetica" value="Helvetica" />
+                <el-option label="Times New Roman" value="Times New Roman" />
+                <el-option label="Georgia" value="Georgia" />
+                <el-option label="Verdana" value="Verdana" />
+                <el-option label="Tahoma" value="Tahoma" />
+                <el-option label="Trebuchet MS" value="Trebuchet MS" />
+                <el-option label="Courier New" value="Courier New" />
+                <el-option label="Impact" value="Impact" />
+                <el-option label="Segoe UI" value="Segoe UI" />
+                <el-option label="Palatino Linotype" value="Palatino Linotype" />
+                <el-option label="Comic Sans MS" value="Comic Sans MS" />
+              </el-option-group>
+            </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" @active-change="(v) => selectedLayer && (selectedLayer.color = v)" />
+          </div>
+          <div class="property-item">
+            <span>背景</span>
+            <el-color-picker v-model="selectedLayer.backgroundColor" size="small" show-alpha @active-change="(v) => selectedLayer && (selectedLayer.backgroundColor = v)" />
+          </div>
+          <div class="property-item">
+            <span>背景圆角</span>
+            <el-slider v-model="selectedLayer.backgroundBorderRadius" :min="0" :max="50" :step="1" />
+          </div>
+          <div class="property-item property-item-align">
+            <span>对齐</span>
+            <div class="align-icons-row">
+              <span class="align-icon" :class="{ active: selectedLayer.textAlign === 'left' }" title="左对齐" @click="selectedLayer.textAlign = 'left'">
+                <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 5h18v2H3V5zm0 4h12v2H3V9zm0 4h18v2H3v-2zm0 4h12v2H3v-2z"/></svg>
+              </span>
+              <span class="align-icon" :class="{ active: selectedLayer.textAlign === 'center' }" title="居中对齐" @click="selectedLayer.textAlign = 'center'">
+                <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 5h18v2H3V5zm3 4h12v2H6V9zm-3 4h18v2H3v-2zm3 4h12v2H6v-2z"/></svg>
+              </span>
+              <span class="align-icon" :class="{ active: selectedLayer.textAlign === 'right' }" title="右对齐" @click="selectedLayer.textAlign = 'right'">
+                <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 5h18v2H3V5zm9 4h6v2h-6V9zm-9 4h18v2H3v-2zm9 4h6v2h-6v-2z"/></svg>
+              </span>
+              <span class="align-icon" :class="{ active: selectedLayer.textAlign === 'justify' }" title="两端对齐" @click="selectedLayer.textAlign = 'justify'">
+                <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 5h18v2H3V5zm0 4h18v2H3V9zm0 4h18v2H3v-2zm0 4h18v2H3v-2z"/></svg>
+              </span>
+            </div>
+          </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 property-item-spacing">
+            <span>字间距</span>
+            <div class="spacing-control">
+              <span class="spacing-icon" title="字间距">
+                <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M4 4h2v16H4V4zm7 4l2-2 2 2-2 2-2-2zm-5 6h14v2H6v-2zm12-6l-2 2 2 2 2-2-2-2z"/></svg>
+              </span>
+              <el-input-number v-model="selectedLayer.letterSpacing" size="small" :min="-5" :max="50" :step="1" controls-position="right" class="spacing-input" />
+            </div>
+          </div>
+          <div class="property-item property-item-spacing">
+            <span>行间距</span>
+            <div class="spacing-control">
+              <span class="spacing-icon" title="行间距">
+                <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
+              </span>
+              <el-input-number v-model="selectedLayer.lineHeight" size="small" :min="0.5" :max="5" :step="0.1" :precision="1" controls-position="right" class="spacing-input" />
+            </div>
+          </div>
+        </template>
+      </section>
+      <section class="props-block">
+        <h4 class="props-block-title">形状属性</h4>
+        <div v-if="!selectedLayer || selectedLayer.type !== 'shape'" class="layer-info-empty">选择形状图层后可编辑</div>
+        <template v-else>
+          <div class="property-item">
+            <span>类型</span>
+            <el-select v-model="selectedLayer.shapeType" size="small" style="flex: 1;" placeholder="选择形状">
+              <el-option label="矩形" value="rect" />
+              <el-option label="圆形" value="circle" />
+              <el-option label="椭圆" value="ellipse" />
+              <el-option label="直线" value="line" />
+            </el-select>
+          </div>
+          <div class="property-item">
+            <span>填充</span>
+            <el-radio-group v-model="selectedLayer.fillMode" size="small">
+              <el-radio-button label="solid">纯色填充</el-radio-button>
+              <el-radio-button label="none">无色填充</el-radio-button>
+            </el-radio-group>
+          </div>
+          <div v-if="selectedLayer.fillMode === 'solid'" class="property-item">
+            <span>填充色</span>
+            <el-color-picker v-model="selectedLayer.fillColor" size="small" show-alpha @active-change="(v) => selectedLayer && (selectedLayer.fillColor = v)" />
+          </div>
+          <div class="property-item">
+            <span>描边色</span>
+            <el-color-picker v-model="selectedLayer.strokeColor" size="small" @active-change="(v) => selectedLayer && (selectedLayer.strokeColor = v)" />
+          </div>
+          <div class="property-item">
+            <span>描边宽度</span>
+            <el-input-number v-model="selectedLayer.strokeWidth" size="small" :min="0" :max="20" :step="1" />
+          </div>
+        </template>
+      </section>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { Rank, ArrowUp, ArrowDown, Delete, View, Hide, Picture, EditPen, List, Setting } from '@element-plus/icons-vue'
+
+defineProps({
+  rightPanelTab: { type: String, default: 'layer' },
+  layerNameEditingId: { type: [String, Number], default: null },
+  layers: { type: Array, default: () => [] },
+  reversedLayers: { type: Array, default: () => [] },
+  selectedLayerId: { type: [String, Number], default: null },
+  dragState: { type: Object, default: () => ({}) },
+  layerListRef: Object,
+  canMoveUp: Boolean,
+  canMoveDown: Boolean,
+  moveLayerUp: { type: Function, required: true },
+  moveLayerDown: { type: Function, required: true },
+  deleteLayer: { type: Function, required: true },
+  selectLayer: { type: Function, required: true },
+  handleDragStart: { type: Function, required: true },
+  handleDragOver: { type: Function, required: true },
+  handleDrop: { type: Function, required: true },
+  handleDragEnd: { type: Function, required: true },
+  handleDragEnter: { type: Function, required: true },
+  handleDragLeave: { type: Function, required: true },
+  setLayerNameInputRef: { type: Function, required: true },
+  openLayerNameEdit: { type: Function, required: true },
+  setLayerNameEditingId: { type: Function, required: true },
+  toggleLayerVisibility: { type: Function, required: true },
+  toggleLayerLock: { type: Function, required: true },
+  deleteLayerById: { type: Function, required: true },
+  getImageLoadUrl: { type: Function, required: true },
+  formatFileSize: { type: Function, required: true },
+  canvasWidth: { type: Number, default: 600 },
+  canvasHeight: { type: Number, default: 450 },
+  canvasRatio: { type: String, default: '4:3' },
+  maintainAspectRatio: { type: Boolean, default: false },
+  selectedLayer: Object,
+  handleCanvasRatioChange: { type: Function, required: true },
+  handleCanvasSizeChange: { type: Function, required: true },
+  handleWidthChange: { type: Function, required: true },
+  handleHeightChange: { type: Function, required: true }
+})
+
+const emit = defineEmits(['update:rightPanelTab', 'update:canvas-width', 'update:canvas-height', 'update:canvas-ratio'])
+</script>

+ 373 - 0
src/view/TemplateManagement/components/MaterialTabPane.vue

@@ -0,0 +1,373 @@
+<template>
+  <div class="toolbar-pane toolbar-pane-scroll">
+    <div class="materials-panel">
+      <el-input
+        :model-value="materialSearch"
+        class="materials-search"
+        size="small"
+        clearable
+        :prefix-icon="Search"
+        placeholder="搜索素材"
+        @update:model-value="onSearchInput"
+      />
+
+      <div
+        class="materials-type-chips-wrap"
+        :class="{ 'has-scroll-left': canScrollChipsLeft, 'has-scroll-right': canScrollChipsRight }"
+      >
+        <button
+          v-if="canScrollChipsLeft"
+          type="button"
+          class="chips-scroll-btn chips-scroll-left"
+          title="向左滚动"
+          @click="scrollChipsLeft"
+        >
+          <el-icon><ArrowLeft /></el-icon>
+        </button>
+        <div class="chips-scroll-inner">
+          <div :ref="el => chipsScrollRef && (chipsScrollRef.value = el)" class="materials-type-chips" @scroll="handleChipsScroll">
+            <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>
+          <div v-if="canScrollChipsLeft" class="chips-fade chips-fade-left" aria-hidden="true"></div>
+          <div v-if="canScrollChipsRight" class="chips-fade chips-fade-right" aria-hidden="true"></div>
+        </div>
+        <button
+          v-if="canScrollChipsRight"
+          type="button"
+          class="chips-scroll-btn chips-scroll-right"
+          title="向右滚动"
+          @click="scrollChipsRight"
+        >
+          <el-icon><ArrowRight /></el-icon>
+        </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" loading="lazy" />
+            <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" loading="lazy" />
+              <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)"
+              >
+                更多 &gt;
+              </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, previewLimit)"
+                :key="material.id"
+                @click="addMaterialToCanvas(material)"
+              >
+                <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" loading="lazy" />
+              </div>
+            </div>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { Search, ArrowLeft, ArrowRight, Plus } from '@element-plus/icons-vue'
+import { resolveMaterialUrl } from '../utils.js'
+
+const props = defineProps({
+  materialSearch: String,
+  materialsLoading: Boolean,
+  activeMaterialType: String,
+  materialViewMode: String,
+  materialsAfterSearch: { type: Array, default: () => [] },
+  materialTypeStats: { type: Array, default: () => [] },
+  materialsByType: { type: Object, default: () => new Map() },
+  detailMaterials: { type: Array, default: () => [] },
+  canScrollChipsLeft: Boolean,
+  canScrollChipsRight: Boolean,
+  chipsScrollRef: { type: Object, default: null },
+  previewLimit: { type: Number, default: 3 },
+  addMaterialToCanvas: { type: Function, required: true },
+  setActiveMaterialType: { type: Function, required: true },
+  openMaterialTypeDetail: { type: Function, required: true },
+  backToMaterialCategory: { type: Function, required: true },
+  handleMaterialSearchInput: { type: Function, required: true },
+  handleChipsScroll: { type: Function, required: true },
+  scrollChipsLeft: { type: Function, required: true },
+  scrollChipsRight: { type: Function, required: true },
+  resolveMaterialUrl: { type: Function, required: true }
+})
+
+const emit = defineEmits(['update:materialSearch'])
+
+const onSearchInput = (v) => {
+  emit('update:materialSearch', v)
+  props.handleMaterialSearchInput?.()
+}
+</script>
+
+<style scoped lang="scss">
+.materials-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  height: 100%;
+  min-height: 0;
+}
+.materials-search { flex-shrink: 0; }
+.materials-search :deep(.el-input__wrapper) { border-radius: 16px; }
+
+.materials-type-chips-wrap {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  position: relative;
+}
+.chips-scroll-btn {
+  flex-shrink: 0;
+  width: 26px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  color: #606266;
+  cursor: pointer;
+}
+.chips-scroll-btn:hover {
+  background: #ecf5ff;
+  color: #409eff;
+  border-color: #c6e2ff;
+}
+.chips-scroll-inner {
+  flex: 1 1 0;
+  min-width: 0;
+  position: relative;
+  display: flex;
+}
+.materials-type-chips {
+  flex: 1 1 0;
+  min-width: 0;
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 6px;
+  overflow-x: auto;
+  padding: 4px 0;
+  min-height: 28px;
+  scroll-behavior: smooth;
+}
+.materials-type-chips-wrap::before,
+.materials-type-chips-wrap::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 12px;
+  pointer-events: none;
+  transition: opacity 0.2s;
+}
+.materials-type-chips-wrap::before {
+  left: 34px;
+  background: linear-gradient(to right, rgba(255,255,255,0.95), transparent);
+  opacity: 0;
+}
+.materials-type-chips-wrap::after {
+  right: 34px;
+  background: linear-gradient(to left, rgba(255,255,255,0.95), transparent);
+  opacity: 0;
+}
+.materials-type-chips-wrap.has-scroll-left::before { opacity: 1; }
+.materials-type-chips-wrap.has-scroll-right::after { opacity: 1; }
+
+.materials-chip {
+  flex-shrink: 0;
+  padding: 4px 10px;
+  border-radius: 14px;
+  border: 1px solid transparent;
+  background: #f3f4f6;
+  color: #606266;
+  font-size: 12px;
+  cursor: pointer;
+  max-width: 88px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.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-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(3, 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; }
+.empty-materials {
+  grid-column: 1 / -1;
+  text-align: center;
+  padding: 20px;
+  color: #999;
+  font-size: 12px;
+}
+</style>

+ 74 - 0
src/view/TemplateManagement/components/PageSidebar.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="page-sidebar">
+    <div class="page-thumb-list">
+      <div
+        v-for="(page, idx) in pages"
+        :key="page.id"
+        class="page-thumb"
+        :class="{ active: currentPageIndex === idx }"
+        @click="switchPage(idx)"
+      >
+        <div class="page-thumb-inner">
+          <span
+            v-if="pages.length > 1"
+            class="page-thumb-remove"
+            title="移除页面"
+            @click.stop="deletePage(idx)"
+          >
+            <el-icon><Close /></el-icon>
+          </span>
+          <div class="page-thumb-canvas">
+            <div
+              v-for="layer in page.layers.slice(0, 5)"
+              :key="layer.id"
+              class="page-thumb-layer"
+              :style="thumbLayerStyle(layer)"
+            >
+              <template v-if="layer.type === 'text'">
+                <span class="page-thumb-text">T</span>
+              </template>
+              <template v-else-if="layer.type === 'shape'">
+                <span class="page-thumb-shape">□</span>
+              </template>
+              <template v-else-if="layer.url">
+                <img :src="getImageLoadUrl(layer.url)" alt="" />
+              </template>
+            </div>
+          </div>
+          <span class="page-thumb-num">{{ idx + 1 }}</span>
+        </div>
+      </div>
+      <div class="page-thumb page-thumb-add" @click="addPage" title="添加页面">
+        <div class="page-thumb-inner">
+          <el-icon class="add-page-icon"><Plus /></el-icon>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { Plus, Close } from '@element-plus/icons-vue'
+
+const props = defineProps({
+  pages: { type: Array, required: true },
+  currentPageIndex: { type: Number, default: 0 },
+  canvasWidth: { type: Number, default: 600 },
+  canvasHeight: { type: Number, default: 450 },
+  switchPage: { type: Function, required: true },
+  deletePage: { type: Function, required: true },
+  addPage: { type: Function, required: true },
+  getImageLoadUrl: { type: Function, required: true }
+})
+
+const thumbLayerStyle = (layer) => {
+  const cw = props.canvasWidth || 600
+  const ch = props.canvasHeight || 450
+  return {
+    left: (cw ? (layer.x / cw) * 100 : 0) + '%',
+    top: (ch ? (layer.y / ch) * 100 : 0) + '%',
+    width: (cw ? (layer.width / cw) * 100 : 10) + '%',
+    height: (ch ? (layer.height / ch) * 100 : 10) + '%'
+  }
+}
+</script>

+ 852 - 0
src/view/TemplateManagement/composables/useCanvasLayers.js

@@ -0,0 +1,852 @@
+/**
+ * useCanvasLayers - Canvas, page, layer state and methods for CreateTemplate
+ */
+import { ref, computed, reactive, nextTick } from 'vue'
+import { resolveMaterialUrl, getImageLoadUrl, toStoragePath } from '../utils.js'
+import { SHAPE_PRESETS, TEXT_PRESETS, RATIO_CONFIG } from '../constants.js'
+
+export function useCanvasLayers() {
+  const canvasRef = ref(null)
+  const canvasAreaRef = ref(null)
+  const layerListRef = ref(null)
+  const currentTool = ref('select')
+  const canvasWidth = ref(600)
+  const canvasHeight = ref(450)
+  const canvasRatio = ref('4:3')
+  const zoomLevel = ref(100)
+  const maintainAspectRatio = ref(false)
+
+  let pageIdCounter = 0
+  const pages = ref([{ id: ++pageIdCounter, layers: [] }])
+  const currentPageIndex = ref(0)
+  const layers = ref(pages.value[0].layers)
+  const selectedLayerId = ref(null)
+
+  const dragState = reactive({
+    draggingId: null,
+    dragOverId: null,
+    draggedLayer: null
+  })
+  const layerNameEditingId = ref(null)
+  const addTextViewMode = ref('list')
+  const addShapeViewMode = ref('list')
+  const textLayerCount = ref(0)
+
+  let layerIdCounter = 0
+
+  const selectedLayer = computed(() =>
+    layers.value.find(l => l.id === selectedLayerId.value) || null
+  )
+  const reversedLayers = computed(() => [...layers.value].reverse())
+  const selectedLayerIndex = computed(() =>
+    layers.value.findIndex(l => l.id === selectedLayerId.value)
+  )
+  const canMoveUp = computed(
+    () => selectedLayerId.value && selectedLayerIndex.value < layers.value.length - 1
+  )
+  const canMoveDown = computed(
+    () => selectedLayerId.value && selectedLayerIndex.value > 0
+  )
+
+  function mapRowToLayer(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' : row.layer_type === 'shape' ? 'shape' : 'image',
+      url: row.layer_type === 'shape' ? '' : 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',
+      shapeType: row.shape_type || 'rect',
+      fillMode: row.fill_mode || 'solid',
+      fillColor: row.fill_color || '#e0e0e0',
+      strokeColor: row.stroke_color || '#606266',
+      strokeWidth: Number(row.stroke_width ?? 2) || 2,
+      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,
+      backgroundBorderRadius: Number(row.background_border_radius ?? 0) || 0,
+      originalWidth: width,
+      originalHeight: height,
+      zIndex: Number(row.z_index || 0) || 0,
+      materialId: row.material_id,
+      templateId: row.template_id
+    }
+  }
+
+  const syncLayersToCurrentPage = () => {
+    const p = pages.value[currentPageIndex.value]
+    layers.value = p ? p.layers : []
+  }
+
+  const addPage = () => {
+    pages.value.push({ id: ++pageIdCounter, layers: [] })
+    currentPageIndex.value = pages.value.length - 1
+    syncLayersToCurrentPage()
+    selectedLayerId.value = null
+  }
+
+  const switchPage = (index) => {
+    if (index < 0 || index >= pages.value.length) return
+    currentPageIndex.value = index
+    syncLayersToCurrentPage()
+    const pageLayers = pages.value[index]?.layers ?? []
+    selectedLayerId.value = pageLayers.length
+      ? pageLayers[pageLayers.length - 1].id
+      : null
+  }
+
+  const deletePage = (index) => {
+    if (pages.value.length <= 1) return
+    pages.value.splice(index, 1)
+    if (currentPageIndex.value >= pages.value.length) {
+      currentPageIndex.value = Math.max(0, pages.value.length - 1)
+    }
+    syncLayersToCurrentPage()
+    selectedLayerId.value = layers.value.length ? layers.value[layers.value.length - 1].id : null
+  }
+
+  const clearDesign = () => {
+    pageIdCounter = 0
+    pages.value = [{ id: ++pageIdCounter, layers: [] }]
+    currentPageIndex.value = 0
+    syncLayersToCurrentPage()
+    selectedLayerId.value = null
+  }
+
+  function parseDataToPages(data) {
+    if (Array.isArray(data?.pages) && data.pages.length) {
+      pageIdCounter = 0
+      return data.pages.map(p => ({
+        id: ++pageIdCounter,
+        layers: (Array.isArray(p?.layers) ? p.layers : []).map(mapRowToLayer)
+      }))
+    }
+    let rows = Array.isArray(data) ? data : (data?.layers ?? data?.data ?? [])
+    if (!Array.isArray(rows) || !rows.length) return null
+
+    const hasPageIndex = rows.some(r => r.page_index != null)
+    if (hasPageIndex) {
+      const byPage = new Map()
+      for (const row of rows) {
+        const idx = Number(row.page_index ?? 0) || 0
+        if (!byPage.has(idx)) byPage.set(idx, [])
+        byPage.get(idx).push(row)
+      }
+      const sorted = [...byPage.entries()].sort((a, b) => a[0] - b[0])
+      pageIdCounter = 0
+      return sorted.map(([, pRows]) => ({
+        id: ++pageIdCounter,
+        layers: pRows.map(mapRowToLayer)
+      }))
+    }
+
+    pageIdCounter = 0
+    return [{ id: ++pageIdCounter, layers: rows.map(mapRowToLayer) }]
+  }
+
+  const processUploadedFile = (file, category) => {
+    return new Promise(resolve => {
+      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,
+            materialType: category,
+            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,
+            fileSize: file.size
+          }
+          layers.value.push(newLayer)
+          selectedLayerId.value = newLayer.id
+          resolve()
+        }
+        img.src = e.target.result
+      }
+      reader.readAsDataURL(file)
+    })
+  }
+
+  const addShapeLayer = (preset) => {
+    const def = SHAPE_PRESETS[preset] || SHAPE_PRESETS.rect
+    const newLayer = {
+      id: ++layerIdCounter,
+      name: `形状 ${layerIdCounter}`,
+      type: 'shape',
+      shapeType: def.shapeType,
+      x: Math.round((canvasWidth.value - def.width) / 2),
+      y: Math.round((canvasHeight.value - def.height) / 2),
+      width: def.width,
+      height: def.height,
+      rotation: 0,
+      opacity: 100,
+      visible: true,
+      locked: false,
+      fillColor: '#e0e0e0',
+      fillMode: 'solid',
+      strokeColor: '#606266',
+      strokeWidth: 2
+    }
+    layers.value.push(newLayer)
+    selectedLayerId.value = newLayer.id
+  }
+
+  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',
+      backgroundBorderRadius: 0,
+      lineHeight: 1.5,
+      letterSpacing: 0
+    }
+    layers.value.push(newLayer)
+    selectedLayerId.value = newLayer.id
+    textLayerCount.value++
+  }
+
+  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
+    }
+    img.src = resolveMaterialUrl(material.material_url)
+  }
+
+  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 getShapeStyle = (layer) => {
+    const fillMode = layer.fillMode || 'solid'
+    const fill = fillMode === 'none' ? 'transparent' : (layer.fillColor || '#e0e0e0')
+    const stroke = layer.strokeColor || '#606266'
+    const sw = layer.strokeWidth ?? 2
+    const isLine = layer.shapeType === 'line'
+    let borderRadius = '0'
+    if (layer.shapeType === 'circle') borderRadius = '50%'
+    else if (layer.shapeType === 'ellipse') borderRadius = '50%'
+    return {
+      width: '100%',
+      height: '100%',
+      backgroundColor: isLine ? stroke : fill,
+      border: isLine ? 'none' : `${sw}px solid ${stroke}`,
+      borderRadius,
+      boxSizing: 'border-box'
+    }
+  }
+
+  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,
+      borderRadius: (layer.backgroundBorderRadius ?? 0) + 'px',
+      lineHeight: layer.lineHeight,
+      letterSpacing: layer.letterSpacing + 'px',
+      padding: '4px 8px',
+      whiteSpace: 'pre-wrap',
+      userSelect: 'none'
+    }
+  }
+
+  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) {
+      ;[layers.value[index], layers.value[index + 1]] = [layers.value[index + 1], layers.value[index]]
+    }
+  }
+  const moveLayerDown = () => {
+    const index = selectedLayerIndex.value
+    if (index > 0) {
+      ;[layers.value[index], layers.value[index - 1]] = [layers.value[index - 1], layers.value[index]]
+    }
+  }
+  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
+    }
+  }
+  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 openTextAddMore = () => { addTextViewMode.value = 'more' }
+  const backTextAddList = () => { addTextViewMode.value = 'list' }
+  const openShapeAddMore = () => { addShapeViewMode.value = 'more' }
+  const backShapeAddList = () => { addShapeViewMode.value = 'list' }
+
+  // Layer name edit
+  const layerNameInputRefs = new Map()
+  const setLayerNameInputRef = (id, el) => {
+    if (el) layerNameInputRefs.set(id, el)
+    else layerNameInputRefs.delete(id)
+  }
+  const setLayerNameEditingId = (id) => {
+    layerNameEditingId.value = id
+  }
+  const openLayerNameEdit = (layer) => {
+    layerNameEditingId.value = layer.id
+    nextTick(() => {
+      const inputComp = layerNameInputRefs.get(layer.id)
+      inputComp?.focus?.()
+    })
+  }
+
+  // Drag/drop for layer list
+  const handleDragStart = (e, layer) => {
+    if (layerNameEditingId.value === layer.id) { e.preventDefault(); return }
+    if (layer.locked) { e.preventDefault(); return }
+    dragState.draggingId = layer.id
+    dragState.draggedLayer = layer
+    e.dataTransfer.effectAllowed = 'move'
+    e.dataTransfer.setData('text/plain', layer.id)
+    if (e.target?.style) e.target.style.opacity = '0.5'
+  }
+  const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }
+  const handleDragEnter = (e, layer) => {
+    e.preventDefault()
+    if (dragState.draggingId !== layer.id) dragState.dragOverId = layer.id
+  }
+  const handleDragLeave = (e) => {
+    if (!e.currentTarget.contains(e.relatedTarget)) dragState.dragOverId = null
+  }
+  const handleDrop = (e, targetLayer) => {
+    e.preventDefault()
+    e.stopPropagation()
+    const draggedId = dragState.draggingId
+    if (draggedId && draggedId !== targetLayer.id) {
+      const draggedIndex = layers.value.findIndex(l => l.id === draggedId)
+      const targetIndex = layers.value.findIndex(l => l.id === targetLayer.id)
+      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?.style) e.target.style.opacity = '1'
+    dragState.draggingId = null
+    dragState.dragOverId = null
+    dragState.draggedLayer = null
+  }
+
+  // Canvas interaction
+  let isDragging = false, isResizing = false, isRotating = false
+  let dragStartX = 0, dragStartY = 0
+  let layerStartX = 0, layerStartY = 0
+  let resizeDirection = ''
+  let startWidth = 0, startHeight = 0
+  let centerX = 0, centerY = 0
+
+  const getCanvasScale = () => {
+    const el = canvasRef.value
+    if (!el) return { scaleX: 1, scaleY: 1 }
+    const rect = el.getBoundingClientRect()
+    return {
+      scaleX: canvasWidth.value / (rect.width || 1),
+      scaleY: canvasHeight.value / (rect.height || 1)
+    }
+  }
+
+  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) => {
+    if (canvasRef.value && e.target === canvasRef.value) selectedLayerId.value = null
+  }
+
+  const handleCanvasMouseMove = (e) => {
+    if (!selectedLayer.value) return
+    const { scaleX, scaleY } = getCanvasScale()
+    if (isDragging) {
+      const deltaX = (e.clientX - dragStartX) * scaleX
+      const deltaY = (e.clientY - dragStartY) * scaleY
+      selectedLayer.value.x = Math.round(layerStartX + deltaX)
+      selectedLayer.value.y = Math.round(layerStartY + deltaY)
+    }
+    if (isResizing) {
+      const deltaX = (e.clientX - dragStartX) * scaleX
+      const deltaY = (e.clientY - dragStartY) * scaleY
+      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 (['se', 'nw', 'ne', 'sw'].includes(resizeDirection)) 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()
+      if (rect) {
+        const mouseX = (e.clientX - rect.left) * scaleX
+        const mouseY = (e.clientY - rect.top) * scaleY
+        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
+    centerX = selectedLayer.value.x + selectedLayer.value.width / 2
+    centerY = selectedLayer.value.y + selectedLayer.value.height / 2
+  }
+
+  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
+      if (canvas) {
+        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 = RATIO_CONFIG[ratio]
+    if (config) {
+      canvasWidth.value = config.width
+      canvasHeight.value = config.height
+    }
+  }
+
+  const handleCanvasSizeChange = () => {
+    if (canvasRatio.value !== 'custom') {
+      const config = RATIO_CONFIG[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 handleCanvasWheel = (e) => {
+    e.preventDefault()
+    const delta = e.deltaY > 0 ? -10 : 10
+    zoomLevel.value = Math.max(10, Math.min(200, zoomLevel.value + delta))
+  }
+
+  // generateCanvasPreview & layerToApiShape
+  const generateCanvasPreview = async (layersOverride) => {
+    const targetLayers = layersOverride ?? layers.value
+    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 = targetLayers.filter(l => (l.type || 'image') !== 'text' && l.url)
+    for (const layer of imageLayers) {
+      const url = layer.url
+      if (url.startsWith('data:')) {
+        const img = new Image()
+        await new Promise(r => { img.onload = () => r(); img.onerror = () => r(); img.src = url })
+        layer._previewImg = img.complete && img.naturalWidth ? img : null
+        continue
+      }
+      const fetchUrl = getImageLoadUrl(url)
+      let img = null
+      try {
+        const res = await fetch(fetchUrl)
+        if (res.ok) {
+          const blob = await res.blob()
+          const objUrl = URL.createObjectURL(blob)
+          img = new Image()
+          await new Promise((r, reject) => { img.onload = () => r(); img.onerror = () => reject(); img.src = objUrl })
+          URL.revokeObjectURL(objUrl)
+        }
+      } catch {
+        img = new Image()
+        img.crossOrigin = 'anonymous'
+        await new Promise(r => { img.onload = () => r(); img.onerror = () => r(); img.src = fetchUrl })
+        if (!img.complete || !img.naturalWidth) img = null
+      }
+      layer._previewImg = img
+    }
+
+    const layersToDraw = [...targetLayers]
+    for (const layer of layersToDraw) {
+      if (!layer.visible) continue
+      ctx.save()
+      ctx.beginPath()
+      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'
+        const paddingTop = 4
+        const paddingLeft = 8
+        const paddingRight = 8
+        const bgColor = layer.backgroundColor
+        if (bgColor && bgColor !== 'transparent') {
+          ctx.fillStyle = bgColor
+          const br = layer.backgroundBorderRadius ?? 0
+          if (br > 0 && typeof ctx.roundRect === 'function') {
+            ctx.beginPath()
+            ctx.roundRect(-w / 2, -h / 2, w, h, br)
+            ctx.fill()
+          } else ctx.fillRect(-w / 2, -h / 2, w, h)
+          ctx.beginPath()
+        }
+        ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
+        ctx.textAlign = (layer.textAlign || 'left') === 'justify' ? 'left' : (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 + paddingLeft
+        else if (ctx.textAlign === 'right') startX = w / 2 - paddingRight
+        let y = -h / 2 + paddingTop
+        for (const line of lines) {
+          ctx.fillText(line, startX, y)
+          y += lineHeightPx
+        }
+      } else if (layer.type === 'shape') {
+        const fillMode = layer.fillMode || 'solid'
+        const fill = fillMode === 'none' ? 'transparent' : (layer.fillColor || '#e0e0e0')
+        const stroke = layer.strokeColor || '#606266'
+        const sw = layer.strokeWidth ?? 2
+        const isLine = layer.shapeType === 'line'
+        const x = -w / 2
+        const y = -h / 2
+        ctx.fillStyle = isLine ? stroke : fill
+        ctx.strokeStyle = stroke
+        ctx.lineWidth = isLine ? Math.max(1, Math.min(w, h)) : sw
+        if (layer.shapeType === 'circle') {
+          ctx.beginPath()
+          ctx.arc(0, 0, Math.min(w, h) / 2, 0, Math.PI * 2)
+          ctx.fill()
+          if (!isLine) ctx.stroke()
+        } else if (layer.shapeType === 'ellipse') {
+          ctx.beginPath()
+          ctx.ellipse(0, 0, w / 2, h / 2, 0, 0, Math.PI * 2)
+          ctx.fill()
+          if (!isLine) ctx.stroke()
+        } else {
+          ctx.fillRect(x, y, w, h)
+          if (!isLine) ctx.strokeRect(x, y, w, h)
+        }
+      } 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
+    try {
+      return exportCanvas.toDataURL('image/png')
+    } catch (e) {
+      console.warn('画布预览图生成失败:', e)
+      return null
+    }
+  }
+
+  const layerToApiShape = (layer, pageIndex) => ({
+    id: layer.id,
+    name: layer.name,
+    type: layer.type || 'image',
+    url: layer.url ? toStoragePath(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 ?? '',
+    material_type: layer.materialType || (layer.url && String(layer.url).startsWith('data:') ? '其他' : ''),
+    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,
+    background_border_radius: layer.backgroundBorderRadius ?? 0,
+    originalWidth: layer.originalWidth,
+    originalHeight: layer.originalHeight,
+    shape_type: layer.type === 'shape' ? (layer.shapeType || '') : '',
+    fill_mode: layer.type === 'shape' ? (layer.fillMode || 'solid') : '',
+    fill_color: layer.type === 'shape' ? (layer.fillColor || '') : '',
+    stroke_color: layer.type === 'shape' ? (layer.strokeColor || '') : '',
+    stroke_width: layer.type === 'shape' ? (layer.strokeWidth ?? 2) : '',
+    page_index: pageIndex
+  })
+
+  return {
+    canvasRef,
+    canvasAreaRef,
+    layerListRef,
+    currentTool,
+    canvasWidth,
+    canvasHeight,
+    canvasRatio,
+    zoomLevel,
+    maintainAspectRatio,
+    pages,
+    currentPageIndex,
+    layers,
+    selectedLayerId,
+    dragState,
+    layerNameEditingId,
+    addTextViewMode,
+    addShapeViewMode,
+    textLayerCount,
+    selectedLayer,
+    reversedLayers,
+    selectedLayerIndex,
+    canMoveUp,
+    canMoveDown,
+    mapRowToLayer,
+    syncLayersToCurrentPage,
+    addPage,
+    switchPage,
+    deletePage,
+    clearDesign,
+    parseDataToPages,
+    processUploadedFile,
+    addShapeLayer,
+    addTextLayer,
+    addMaterialToCanvas,
+    getLayerStyle,
+    getShapeStyle,
+    getTextStyle,
+    getImageLoadUrl,
+    selectLayer,
+    toggleLayerVisibility,
+    toggleLayerLock,
+    moveLayerUp,
+    moveLayerDown,
+    deleteLayer,
+    deleteLayerById,
+    openTextAddMore,
+    backTextAddList,
+    openShapeAddMore,
+    backShapeAddList,
+    toStoragePath,
+    RATIO_CONFIG,
+    setLayerNameInputRef,
+    setLayerNameEditingId,
+    openLayerNameEdit,
+    handleDragStart,
+    handleDragOver,
+    handleDrop,
+    handleDragEnd,
+    handleDragEnter,
+    handleDragLeave,
+    handleLayerMouseDown,
+    handleCanvasMouseDown,
+    handleCanvasMouseMove,
+    handleCanvasMouseUp,
+    handleCanvasWheel,
+    startResize,
+    startRotate,
+    handleLayerDblClick,
+    handleCanvasRatioChange,
+    handleCanvasSizeChange,
+    handleWidthChange,
+    handleHeightChange,
+    generateCanvasPreview,
+    layerToApiShape
+  }
+}

+ 140 - 0
src/view/TemplateManagement/composables/useMaterials.js

@@ -0,0 +1,140 @@
+/**
+ * useMaterials - Materials logic for CreateTemplate
+ */
+import { ref, computed } from 'vue'
+import { nextTick } from 'vue'
+import { Material_List } from '@/api/mes/job'
+import { resolveMaterialUrl } from '../utils.js'
+import { PREVIEW_LIMIT } from '../constants.js'
+
+export function useMaterials() {
+  const materials = ref([])
+  const materialsLoading = ref(false)
+  const materialSearch = ref('')
+  const activeMaterialType = ref('全部')
+  const materialViewMode = ref('category')
+  const chipsScrollRef = ref(null)
+  const canScrollChipsLeft = ref(false)
+  const canScrollChipsRight = ref(false)
+
+  const materialsAfterSearch = computed(() => {
+    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)
+    })
+  })
+
+  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)
+    }
+    for (const [, 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 updateChipsScrollState = () => {
+    const el = chipsScrollRef.value
+    if (!el) return
+    canScrollChipsLeft.value = el.scrollLeft > 0
+    canScrollChipsRight.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
+  }
+
+  const handleChipsScroll = () => updateChipsScrollState()
+
+  const scrollChipsLeft = () => {
+    const el = chipsScrollRef.value
+    if (el) el.scrollBy({ left: -120, behavior: 'smooth' })
+  }
+
+  const scrollChipsRight = () => {
+    const el = chipsScrollRef.value
+    if (el) el.scrollBy({ left: 120, behavior: 'smooth' })
+  }
+
+  let materialSearchTimer = null
+  const handleMaterialSearchInput = () => {
+    if (materialSearchTimer) clearTimeout(materialSearchTimer)
+    materialSearchTimer = setTimeout(() => {
+      fetchMaterials()
+    }, 250)
+  }
+
+  const fetchMaterials = async () => {
+    try {
+      materialsLoading.value = true
+      const response = await Material_List({ search: (materialSearch.value || '').trim() })
+      if (response.code === 0) {
+        materials.value = response.data
+      }
+    } finally {
+      materialsLoading.value = false
+      nextTick(updateChipsScrollState)
+    }
+  }
+
+  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'
+  }
+
+  return {
+    materials,
+    materialsLoading,
+    materialSearch,
+    activeMaterialType,
+    materialViewMode,
+    chipsScrollRef,
+    canScrollChipsLeft,
+    canScrollChipsRight,
+    materialsAfterSearch,
+    materialTypeStats,
+    materialsByType,
+    detailMaterials,
+    fetchMaterials,
+    setActiveMaterialType,
+    openMaterialTypeDetail,
+    backToMaterialCategory,
+    handleMaterialSearchInput,
+    handleChipsScroll,
+    scrollChipsLeft,
+    scrollChipsRight,
+    updateChipsScrollState,
+    resolveMaterialUrl,
+    PREVIEW_LIMIT
+  }
+}

+ 266 - 0
src/view/TemplateManagement/composables/useTemplateData.js

@@ -0,0 +1,266 @@
+/**
+ * useTemplateData - 模版加载、保存、上传等业务逻辑
+ */
+import { ref, watch, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useUserStore } from '@/pinia/modules/user'
+import { Template_Material_Add, Template_Material_Update, Template_Material_Relation } from '@/api/mes/job'
+import { MAX_IMAGE_SIZE_MB, MATERIAL_CATEGORIES, TEMPLATE_NAME_MAX_LEN } from '../constants.js'
+
+export function useTemplateData(props, emit, canvasLayers, materials) {
+  const {
+    pages,
+    layers,
+    currentPageIndex,
+    selectedLayerId,
+    syncLayersToCurrentPage,
+    canvasWidth,
+    canvasHeight,
+    canvasRatio,
+    clearDesign,
+    parseDataToPages,
+    processUploadedFile,
+    addMaterialToCanvas,
+    generateCanvasPreview,
+    layerToApiShape,
+    getImageLoadUrl
+  } = canvasLayers
+
+  const editingTemplateId = ref(null)
+  const templateName = ref('未命名模版')
+  const templateNameElRef = ref(null)
+  const templatesLoading = ref(false)
+
+  const uploadCategoryDialogVisible = ref(false)
+  const pendingUploadFiles = ref([])
+  const dialogSelectedCategory = ref('')
+
+  const aiPromptDialogVisible = ref(false)
+  const aiPromptToolName = ref('')
+
+  const userStore = useUserStore()
+
+  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 useTemplate = async (template) => {
+    if (!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
+      const parsed = parseDataToPages(data)
+      if (!parsed?.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)
+      pages.value = parsed
+      currentPageIndex.value = 0
+      syncLayersToCurrentPage()
+      selectedLayerId.value = layers.value.length ? layers.value[layers.value.length - 1].id : null
+      editingTemplateId.value = template.id
+      templateName.value = template.template_name || '未命名模版'
+    } catch (e) {
+      console.error('useTemplate error:', e)
+      ElMessage.error('获取模板数据失败')
+    } finally {
+      templatesLoading.value = false
+    }
+  }
+
+  const useAsTemplate = async (template) => {
+    if (!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
+      const parsed = parseDataToPages(data)
+      if (!parsed?.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)
+      pages.value = parsed
+      currentPageIndex.value = 0
+      syncLayersToCurrentPage()
+      selectedLayerId.value = layers.value.length ? layers.value[layers.value.length - 1].id : null
+      templateName.value = template.template_name ? template.template_name + ' (同款)' : '未命名模版'
+    } catch (e) {
+      console.error('useAsTemplate error:', e)
+      ElMessage.error('获取模板数据失败')
+    } finally {
+      templatesLoading.value = false
+    }
+  }
+
+  const loadByMode = () => {
+    const mode = props.mode || 'create'
+    const template = props.initialTemplate
+    if (mode === 'create' || !template) {
+      editingTemplateId.value = null
+      templateName.value = '未命名模版'
+      clearDesign()
+      return
+    }
+    if (mode === 'edit') return useTemplate(template)
+    if (mode === 'copy') return useAsTemplate(template)
+  }
+
+  const saveTemplate = async () => {
+    const flatLayers = pages.value.flatMap((p, i) => p.layers.map(l => ({ ...l, _pageIndex: i })))
+    if (!flatLayers.length) {
+      ElMessage.warning('请先添加图片或文字,再生成模版')
+      return
+    }
+    const previewLayers = pages.value[0]?.layers ?? []
+    const previewImage = await generateCanvasPreview(previewLayers.length ? previewLayers : undefined)
+    const uploadedMaterials = flatLayers
+      .filter(l => l.type !== 'text' && l.url && typeof l.url === 'string' && l.url.startsWith('data:'))
+      .map(l => ({ layer_id: l.id, name: l.name, data: l.url, type: l.materialType || '其他' }))
+    const templateData = {
+      template_name: templateName.value || '未命名模版',
+      sys_id: userStore.userInfo.nickName,
+      canvasWidth: canvasWidth.value,
+      canvasHeight: canvasHeight.value,
+      canvasRatio: canvasRatio.value,
+      previewImage,
+      uploaded_materials: uploadedMaterials,
+      layers: flatLayers.map(layer => layerToApiShape(layer, layer._pageIndex ?? 0))
+    }
+    const isUpdate = !!editingTemplateId.value
+    try {
+      const res = isUpdate
+        ? await Template_Material_Update({ template_id: editingTemplateId.value, ...templateData })
+        : await Template_Material_Add(templateData)
+      if (res?.code === 0) {
+        ElMessage.success(isUpdate ? '模版已保存修改!' : '模版生成成功!')
+        editingTemplateId.value = null
+        emit('back')
+      } else {
+        ElMessage.error(res?.msg || (isUpdate ? '模版保存失败' : '模版生成失败'))
+      }
+    } catch (e) {
+      console.error('saveTemplate error:', e)
+      ElMessage.error(isUpdate ? '模版保存失败' : '模版生成失败')
+    }
+  }
+
+  const beforeUpload = (file) => {
+    if (!file.type.startsWith('image/')) {
+      ElMessage.error('只能上传图片文件!')
+      return false
+    }
+    const sizeMB = file.size / 1024 / 1024
+    if (sizeMB > MAX_IMAGE_SIZE_MB) {
+      ElMessage.error(`图片大小不能超过 ${MAX_IMAGE_SIZE_MB}MB,当前为 ${sizeMB.toFixed(2)}MB`)
+      return false
+    }
+    pendingUploadFiles.value.push(file)
+    nextTick(() => {
+      if (!uploadCategoryDialogVisible.value) {
+        dialogSelectedCategory.value = ''
+        uploadCategoryDialogVisible.value = true
+      }
+    })
+    return false
+  }
+
+  const handleUploadCategoryCancel = (uploadRefOrEl) => {
+    pendingUploadFiles.value = []
+    dialogSelectedCategory.value = ''
+    uploadCategoryDialogVisible.value = false
+    const uploadEl = uploadRefOrEl?.value ?? uploadRefOrEl
+    uploadEl?.clearFiles?.()
+  }
+
+  const handleUploadCategoryConfirm = async (uploadRefOrEl) => {
+    const category = dialogSelectedCategory.value
+    if (!category || !pendingUploadFiles.value.length) return
+    const files = [...pendingUploadFiles.value]
+    handleUploadCategoryCancel(uploadRefOrEl)
+    for (const file of files) {
+      await processUploadedFile(file, category)
+    }
+    ElMessage.success(`已上传 ${files.length} 个素材`)
+  }
+
+  watch([() => props.mode, () => props.initialTemplate], loadByMode)
+  watch(templateName, setTemplateNameElContent)
+
+  return {
+    editingTemplateId,
+    templateName,
+    templateNameElRef,
+    templatesLoading,
+    uploadCategoryDialogVisible,
+    pendingUploadFiles,
+    dialogSelectedCategory,
+    aiPromptDialogVisible,
+    aiPromptToolName,
+    syncTemplateNameFromEl,
+    enforceTemplateNameMaxLength,
+    focusTemplateNameEl,
+    setTemplateNameElContent,
+    useTemplate,
+    useAsTemplate,
+    loadByMode,
+    saveTemplate,
+    beforeUpload,
+    handleUploadCategoryCancel,
+    handleUploadCategoryConfirm,
+    MATERIAL_CATEGORIES,
+    MAX_IMAGE_SIZE_MB
+  }
+}

+ 49 - 0
src/view/TemplateManagement/constants.js

@@ -0,0 +1,49 @@
+/**
+ * CreateTemplate 模版设计页常量
+ * 集中管理配置,便于调试与维护
+ */
+
+/** 模版名称最大字符数 */
+export const TEMPLATE_NAME_MAX_LEN = 12
+
+/** 上传图片最大尺寸 MB */
+export const MAX_IMAGE_SIZE_MB = 1
+
+/** 素材分类(上传时选择) */
+export const MATERIAL_CATEGORIES = ['化妆品', '酒类', '保健品', '食品', '日用品', '背景', '烟类', '其他']
+
+/** 素材库分类预览每个分类展示数量 */
+export const PREVIEW_LIMIT = 3
+
+/** 画布比例配置 */
+export const RATIO_CONFIG = {
+  '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 }
+}
+
+/** AI 工具列表 */
+export const AI_TOOL_LIST = [
+  { key: 'hd', name: 'Ai高清', 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: '文图' }
+]
+
+/** 形状预设(添加形状图层时的默认尺寸) */
+export const SHAPE_PRESETS = {
+  rect: { width: 80, height: 60, shapeType: 'rect' },
+  circle: { width: 60, height: 60, shapeType: 'circle' },
+  ellipse: { width: 80, height: 40, shapeType: 'ellipse' },
+  line: { width: 100, height: 3, shapeType: 'line' }
+}
+
+/** 文字预设(标题/副标题/正文) */
+export const TEXT_PRESETS = {
+  title: { fontSize: 32, fontWeight: 'bold', text: '标题文字', name: '标题' },
+  subtitle: { fontSize: 22, fontWeight: 'normal', text: '副标题', name: '副标题' },
+  body: { fontSize: 16, fontWeight: 'normal', text: '正文内容', name: '正文' }
+}

+ 2196 - 0
src/view/TemplateManagement/styles/CreateTemplate.scss

@@ -0,0 +1,2196 @@
+.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;
+}
+
+/* 左侧栏:与画布同高,宽度约 3 张素材图,整体更紧凑 */
+.toolbar {
+  flex: 0 0 290px;
+  width: 290px;
+  min-width: 290px;
+  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 {
+  flex-shrink: 0;
+}
+.materials-search :deep(.el-input__wrapper) {
+  border-radius: 16px;
+}
+
+/* 标签栏:左右箭头 + 横向滚动 + 渐变遮罩 */
+.materials-type-chips-wrap {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  position: relative;
+}
+.chips-scroll-btn {
+  flex-shrink: 0;
+  width: 26px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  color: #606266;
+  cursor: pointer;
+  transition: background 0.2s, color 0.2s, opacity 0.2s;
+}
+.chips-scroll-btn:hover {
+  background: #ecf5ff;
+  color: #409eff;
+  border-color: #c6e2ff;
+}
+.chips-scroll-btn:active {
+  background: #d9ecff;
+}
+.chips-scroll-btn .el-icon {
+  font-size: 14px;
+}
+
+.chips-scroll-inner {
+  flex: 1 1 0;
+  min-width: 0;
+  position: relative;
+  display: flex;
+}
+
+.materials-type-chips {
+  flex: 1 1 0;
+  min-width: 0;
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 6px;
+  overflow-x: auto;
+  overflow-y: hidden;
+  align-items: center;
+  padding: 4px 0;
+  min-height: 28px;
+  scroll-behavior: smooth;
+  -webkit-overflow-scrolling: touch;
+}
+.materials-type-chips::-webkit-scrollbar { height: 4px; }
+.materials-type-chips::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 2px; }
+.materials-type-chips::-webkit-scrollbar-thumb:hover { background: #909399; }
+
+/* 左右渐变遮罩:提示可滚动 */
+.materials-type-chips-wrap::before,
+.materials-type-chips-wrap::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 12px;
+  pointer-events: none;
+  transition: opacity 0.2s;
+}
+.materials-type-chips-wrap::before {
+  left: 34px; /* 左箭头宽度 + gap */
+  background: linear-gradient(to right, rgba(255,255,255,0.95), transparent);
+  opacity: 0;
+}
+.materials-type-chips-wrap::after {
+  right: 34px;
+  background: linear-gradient(to left, rgba(255,255,255,0.95), transparent);
+  opacity: 0;
+}
+.materials-type-chips-wrap.has-scroll-left::before { opacity: 1; }
+.materials-type-chips-wrap.has-scroll-right::after { opacity: 1; }
+.materials-chip-more {
+  flex-shrink: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+}
+.chip-more-icon {
+  font-size: 12px;
+  transition: transform 0.2s;
+}
+
+.materials-chip {
+  flex-shrink: 0;
+  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;
+  max-width: 88px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.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(3, 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;
+}
+
+/* 上传前选择素材分类 */
+.upload-category-section {
+  margin-bottom: 10px;
+}
+.upload-category-label {
+  font-size: 12px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 8px;
+}
+.upload-category-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.upload-category-chip {
+  font-size: 11px;
+  padding: 4px 10px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  background: #fff;
+  color: #606266;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+.upload-category-chip:hover {
+  border-color: #409eff;
+  color: #409eff;
+}
+.upload-category-chip.active {
+  border-color: #409eff;
+  background: #ecf5ff;
+  color: #409eff;
+}
+
+.upload-category-dialog-content {
+  padding: 8px 0;
+}
+.upload-category-dialog-tip {
+  font-size: 13px;
+  color: #606266;
+  margin-bottom: 16px;
+}
+.upload-category-dialog-chips {
+  margin-bottom: 12px;
+}
+.upload-category-file-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.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;
+}
+/* 文字标签页:点击添加文本 + 标题/副标题/正文,实线框 + 浅灰底 */
+.add-tab-pane {
+  padding: 12px 20px;
+}
+.add-text-section {
+  margin-top: 10px;
+}
+.text-add-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10px;
+}
+/* 与素材库分组标题一致:12px、600、#303133 */
+.text-add-heading {
+  font-size: 12px;
+  font-weight: 600;
+  color: #303133;
+  margin: 0;
+}
+.text-add-btns {
+  display: flex;
+  gap: 10px;
+}
+.text-add-btns-more {
+  margin-top: 10px;
+}
+.text-add-btn {
+  flex: 1;
+  padding: 10px 12px;
+  font-size: 14px;
+  color: #303133;
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: background 0.2s, border-color 0.2s;
+}
+.text-add-btn:hover {
+  background: #eef1f6;
+  border-color: #c0c4cc;
+}
+.text-add-btn-title {
+  font-size: 18px;
+  font-weight: 700;
+}
+.text-add-btn-subtitle {
+  font-size: 15px;
+  font-weight: 500;
+}
+.text-add-btn-body {
+  font-size: 14px;
+  font-weight: 400;
+}
+.text-add-btn-effect {
+  font-size: 13px;
+}
+
+.add-shape-section {
+  margin-top: 16px;
+  padding-top: 12px;
+  border-top: 1px solid #ebeef5;
+}
+.add-shape-section .text-add-heading {
+  margin-bottom: 8px;
+}
+.add-shape-btns {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.add-shape-btns-more {
+  margin-top: 10px;
+}
+.add-shape-btn {
+  padding: 8px 14px;
+  font-size: 13px;
+  color: #303133;
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: background 0.2s, border-color 0.2s;
+}
+.add-shape-btn:hover {
+  background: #eef1f6;
+  border-color: #c0c4cc;
+}
+.shape-thumb {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  min-height: 36px;
+}
+.shape-thumb-icon {
+  font-size: 18px;
+  color: #606266;
+  opacity: 0.9;
+}
+.shape-thumb-no-fill {
+  background-image: linear-gradient(45deg, #e4e7ed 25%, transparent 25%, transparent 75%, #e4e7ed 75%),
+    linear-gradient(45deg, #e4e7ed 25%, transparent 25%, transparent 75%, #e4e7ed 75%);
+  background-size: 6px 6px;
+  background-position: 0 0, 3px 3px;
+  background-color: #fff !important;
+}
+
+.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;
+}
+
+/* 左侧分页 + 右侧预览:贴合在一起的布局块 */
+.page-preview-block {
+  flex: 1 1 0;
+  min-width: 0;
+  min-height: 0;
+  display: flex;
+  background: linear-gradient(135deg, #f5f6f8 0%, #ebeef3 100%);
+  overflow: hidden;
+  margin: 0;
+  align-items: stretch;
+}
+
+/* 页面缩略图栏(左侧,与预览区无缝贴合) */
+.page-sidebar {
+  flex-shrink: 0;
+  width: 72px;
+  background: rgba(255, 255, 255, 0.6);
+  border-right: 1px solid rgba(0, 0, 0, 0.06);
+  display: flex;
+  flex-direction: column;
+  padding: 12px 8px;
+  overflow-y: auto;
+}
+.page-thumb-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  align-items: center;
+}
+.page-thumb {
+  width: 56px;
+  height: 56px;
+  border: 2px solid transparent;
+  border-radius: 6px;
+  overflow: hidden;
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s;
+  flex-shrink: 0;
+}
+.page-thumb:hover {
+  border-color: #c0c4cc;
+}
+.page-thumb.active {
+  border-color: #409eff;
+  box-shadow: 0 0 0 1px #409eff;
+}
+.page-thumb-inner {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  background: #f5f7fa;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.page-thumb-remove {
+  position: absolute;
+  top: 2px;
+  right: 2px;
+  width: 16px;
+  height: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  color: #606266;
+  background: rgba(255,255,255,0.95);
+  border-radius: 2px;
+  cursor: pointer;
+  z-index: 2;
+  transition: color 0.2s, background 0.2s;
+}
+.page-thumb-remove:hover {
+  color: #f56c6c;
+  background: #fef0f0;
+}
+.page-thumb-canvas {
+  position: absolute;
+  inset: 0;
+}
+.page-thumb-layer {
+  position: absolute;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  font-size: 8px;
+}
+.page-thumb-layer img {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+.page-thumb-text, .page-thumb-shape {
+  font-weight: bold;
+  color: #606266;
+}
+.page-thumb-remove {
+  position: absolute;
+  top: 2px;
+  right: 2px;
+  width: 16px;
+  height: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0,0,0,0.4);
+  color: #fff;
+  border-radius: 2px;
+  font-size: 10px;
+  cursor: pointer;
+  z-index: 2;
+  opacity: 0.8;
+  transition: opacity 0.2s, background 0.2s;
+}
+.page-thumb-remove:hover {
+  opacity: 1;
+  background: #f56c6c;
+}
+.page-thumb-num {
+  position: absolute;
+  bottom: 2px;
+  right: 2px;
+  font-size: 10px;
+  color: #909399;
+  background: rgba(255,255,255,0.9);
+  padding: 0 3px;
+  border-radius: 2px;
+}
+.page-thumb-add {
+  border-style: dashed;
+  border-color: #dcdfe6;
+}
+.page-thumb-add:hover {
+  border-color: #409eff;
+  color: #409eff;
+}
+.add-page-icon {
+  font-size: 24px;
+  color: #c0c4cc;
+}
+.page-thumb-add:hover .add-page-icon {
+  color: #409eff;
+}
+
+/* 中间区域:可伸缩,不把左右两侧挤出视口 */
+.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;
+  max-width: 100px;
+}
+
+.property-item .el-slider {
+  flex: 1;
+  min-width: 0;
+}
+
+/* 对齐图标行 */
+.align-icons-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.align-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 28px;
+  border-radius: 4px;
+  color: #606266;
+  cursor: pointer;
+  transition: background 0.2s, color 0.2s;
+}
+.align-icon:hover { background: #f0f2f5; color: #409eff; }
+.align-icon.active { background: #ecf5ff; color: #409eff; }
+/* 字间距/行间距:图标+数字输入 */
+.property-item-spacing .spacing-control {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+.spacing-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #606266;
+  flex-shrink: 0;
+}
+.spacing-input { flex: 0 0 auto; width: 72px; min-width: 72px; }
+/* 行高/字距:滑块+数值 */
+.property-item-slider-wrap {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+.property-item-value {
+  font-size: 12px;
+  color: #909399;
+  min-width: 36px;
+  text-align: right;
+}
+
+/* 缩小表单元素 */
+: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: transparent;
+  overflow: hidden;
+  padding: 16px;
+  align-items: center;
+  justify-content: center;
+}
+
+.canvas-wrapper {
+  position: relative;
+  flex: 1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  overflow: hidden;
+  width: 100%;
+  max-width: 100%;
+}
+
+/* 预览效果:画布作为文档预览,带纸张质感边框 */
+.canvas {
+  position: relative;
+  background-color: #fff;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+  border-radius: 2px;
+}
+
+.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: block;
+  width: 100%;
+  height: 100%;
+  min-width: 50px;
+  cursor: text;
+  box-sizing: border-box;
+}
+
+.text-layer {
+  border: 1px solid transparent;
+}
+
+.text-layer.selected {
+  border-color: #409eff;
+  box-shadow: 0 0 0 1px #409eff;
+}
+
+.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 260px;
+  width: 260px;
+  min-width: 260px;
+  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;
+  overflow: hidden;
+  font-size: 13px;
+  color: #303133;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.layer-card-name-wrap {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+.layer-card-name-trigger {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+}
+.layer-card-name-trigger .el-popover__reference,
+.layer-card-name-trigger .el-popover__reference-wrapper {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+}
+.layer-card-size {
+  font-size: 11px;
+  color: #909399;
+  flex-basis: 100%;
+  line-height: 1.2;
+}
+.layer-card-name-input {
+  flex: 1;
+  min-width: 0;
+}
+.layer-card-name-edit-icon {
+  flex-shrink: 0;
+  font-size: 14px;
+  color: #909399;
+  cursor: pointer;
+  padding: 2px;
+}
+.layer-card-name-edit-icon:hover {
+  color: #409eff;
+}
+.layer-card-name-input :deep(.el-input__wrapper) {
+  padding: 0 6px;
+  box-shadow: none;
+  background: transparent;
+  min-height: 24px;
+}
+.layer-card-name-input :deep(.el-input__wrapper:hover),
+.layer-card-name-input :deep(.el-input__wrapper.is-focus) {
+  background: #f5f7fa;
+  box-shadow: none;
+}
+.layer-card-name-input :deep(.el-input__inner) {
+  font-size: 13px;
+  color: #303133;
+  height: 24px;
+  line-height: 24px;
+}
+.layer-card-name-input :deep(.el-input__inner::placeholder) {
+  color: #c0c4cc;
+}
+
+.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;
+}
+
+/* 图层名称编辑 Popover(teleport 到 body,需全局样式) */
+.layer-name-edit-popover {
+  min-width: 220px;
+  max-width: 320px;
+}
+.layer-name-edit-popover .layer-name-edit-inner {
+  padding: 4px 0;
+}
+.layer-name-edit-popover .el-input {
+  width: 100%;
+  min-width: 200px;
+}

+ 68 - 0
src/view/TemplateManagement/utils.js

@@ -0,0 +1,68 @@
+/**
+ * CreateTemplate 模版设计页工具函数
+ * 纯函数,无副作用,便于单元测试与复用
+ */
+
+/**
+ * 将 material_url 转为可请求 URL
+ * @param {string} path - 素材路径
+ * @returns {string}
+ */
+export function resolveMaterialUrl(path) {
+  if (!path || typeof path !== 'string') return ''
+  let p = path.trim()
+  if (!p) return ''
+  if (p.startsWith('data:')) return p
+  if (p.startsWith('http://') || p.startsWith('https://')) {
+    try {
+      const u = new URL(p)
+      if (u.port === '9090') return `${u.protocol}//${u.hostname}:9093${u.pathname}${u.search}`
+    } catch {
+      /* ignore */
+    }
+    return p
+  }
+  const pathNorm = (p.startsWith('/') ? p : '/' + p).replace(/^public\//, '')
+  const base = import.meta.env.VITE_BASE_PATH || ''
+  const port = import.meta.env.VITE_UPLOADS_PORT || '8081'
+  const uploadsBase = base && port ? `${base.replace(/:(\d+)?$/, '')}:${port}` : ''
+  return uploadsBase ? `${uploadsBase.replace(/\/$/, '')}${pathNorm}` : pathNorm
+}
+
+/** getImageLoadUrl 的别名,用于画布与预览图 */
+export function getImageLoadUrl(url) {
+  return resolveMaterialUrl(url)
+}
+
+/**
+ * 保存时:把 layer.url 转为仅路径(不要 http 前缀)
+ * @param {string} url
+ * @returns {string}
+ */
+export function toStoragePath(url) {
+  if (!url || typeof url !== 'string') return ''
+  const u = url.trim()
+  if (!u) return ''
+  if (u.startsWith('data:')) return u
+  if (u.startsWith('http://')) {
+    try {
+      const parsed = new URL(u)
+      return parsed.pathname || '/' + u.replace(/^\/+/, '')
+    } catch {
+      return u
+    }
+  }
+  return u.startsWith('/') ? u : '/' + u
+}
+
+/**
+ * 格式化文件大小
+ * @param {number} bytes
+ * @returns {string}
+ */
+export function formatFileSize(bytes) {
+  if (!bytes) return ''
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / 1024 / 1024).toFixed(2)} MB`
+}

部分文件因为文件数量过多而无法显示