liuhairui před 6 dny
rodič
revize
50b5ba346e

binární
public/src/assets/PackAiLogo.png


+ 16 - 3
src/api/mes/job.js

@@ -1386,6 +1386,16 @@ export const Product_Del = (data) => {
   })
 }
 
+//获取示例图片
+export const Get_Images = (params) => {
+  return service({
+    url: '/mes_server/product/Get_Images',
+    method: 'get',
+    params
+  })
+}
+
+
 //查询获取任务数据
 export const GetImageStatus = (params) => {
   return service({
@@ -1447,7 +1457,10 @@ export const Material_RecordUse = (data) => {
   })
 }
 
-//新增模版(生成模版)
+// 新增/修改模版 入参一致(修改加 template_id)
+// previewImage: dataURL 主封面,与第1页缩略一致,列表/回退用
+// preview_images: 可选,多页时 dataURL 数组,下标=页序,与 chinese_description 对齐;可落盘多文件后单字段存 JSON 路径
+// 仍建议 template_image_url/thumbnail 仅绑第1页,多页图另存一列如 page_image_urls(TEXT/JSON)避免 varchar 不够
 export const Template_Material_Add = (data) => {
   return service({
     url: '/mes_server/Material/Template_Material_Add',
@@ -1456,7 +1469,6 @@ export const Template_Material_Add = (data) => {
   })
 }
 
-// 修改模版
 export const Template_Material_Update = (data) => {
   return service({
     url: '/mes_server/Material/Template_Material_Update',
@@ -1570,4 +1582,5 @@ export const UpdateAIModel = (data) => {
     method: 'post',
     data
   })
-}
+}
+

+ 168 - 4
src/utils/displayImageUrl.js

@@ -1,18 +1,182 @@
 /**
- * 展示用图片地址:与接口 material_url 等字段一致,不做域名、端口、OSS 前缀拼接。
- * 后端已返回完整阿里云 OSS 地址时直接使用;data/blob 同理。
+ * 现网与 material_url 同桶;多页仅相对 path 且同条无 https 缩略、且未设 VITE_OSS_ 时兜底,防浏览器用当前站 IP 拼 /uploads
+ *(若联调机文件不在 OSS,可改 .env 的 VITE_OSS_ASSET_BASE 或 VITE_UPLOADS_PUBLIC_ORIGIN)
+ */
+const FALLBACK_OSS_ASSET_BASE = 'https://a-7in6-com.oss-cn-hangzhou.aliyuncs.com'
+
+function envStr(key) {
+  if (typeof import.meta === 'undefined') return ''
+  const v = import.meta.env?.[key]
+  return v != null && String(v).trim() ? String(v).trim() : ''
+}
+
+/** 多页/列表用:与 OSS 公网基座、或内网文件服务(与 vite 中 /uploads 代理 target 一致) */
+function getUploadsAssetBase() {
+  const a = envStr('VITE_OSS_ASSET_BASE')
+  if (a) return a.replace(/\/$/, '')
+  const b = envStr('VITE_UPLOADS_PUBLIC_ORIGIN')
+  if (b) return b.replace(/\/$/, '')
+  return ''
+}
+
+/** 与 Vite proxy target 同构,用于把错写成 localhost:端口/uploads 的整链改到真实文件机 */
+function getFileServerOriginFromEnv() {
+  const path = envStr('VITE_BASE_PATH')
+  const port = envStr('VITE_UPLOADS_PORT')
+  if (!path || !port) return ''
+  return `${path.replace(/\/$/, '')}:${port}`
+}
+
+/** 仅用于「强制走 OSS」:与 getUploadsAssetBase 不同,不回落到 VITE_UPLOADS_PUBLIC_ORIGIN */
+function getOssAssetBaseStrict() {
+  const a = envStr('VITE_OSS_ASSET_BASE')
+  return a ? a.replace(/\/$/, '') : ''
+}
+
+/** 内联 fallback,与 build 时是否注入 VITE_OSS 无关,避免测试环境打包未带 env 时整链被原样使用 */
+function getOssBaseForFullUrlUploadsRewrite() {
+  const s = getOssAssetBaseStrict()
+  if (s) return s
+  return FALLBACK_OSS_ASSET_BASE.replace(/\/$/, '')
+}
+
+/**
+ * 将「非本 OSS 源」的 http(s) 或协议相对 + /uploads/… 整链改写到目标 OSS 根;
+ * 基座为 VITE_OSS_ASSET_BASE,未设时用代码内 FALLBACK,避免测试/后端把地址写成当前站或 IP 时与本地(相对 path)表现不一致。
+ * @param {string} p
+ * @returns {string}
+ */
+function rewriteFullUrlUploadsToOss(p) {
+  const oss = getOssBaseForFullUrlUploadsRewrite()
+  if (!oss) return p
+  if (!p.startsWith('http://') && !p.startsWith('https://') && !p.startsWith('//')) return p
+  let needBase = p
+  if (p.startsWith('//')) needBase = `https:${p}`
+  let cur
+  let target
+  try {
+    cur = new URL(needBase, 'https://a.example/')
+    const ossForUrl = /^https?:\/\//i.test(oss) ? oss : `https://${oss}`
+    target = new URL(ossForUrl)
+  } catch {
+    return p
+  }
+  if (!/^\/?uploads\//i.test(cur.pathname)) return p
+  if (cur.origin === target.origin) return p
+  const tail = (cur.pathname + (cur.search || '')).replace(/^\//, '')
+  return `${oss.replace(/\/$/, '')}/${tail}`
+}
+
+/**
+ * 将接口里误写成「localhost:端口 /uploads/…」的整链改写到实际文件服务或 OSS 根(需 VITE_REWRITE_LOCALHOST_UPLOADS)
+ * @param {string} p
+ * @returns {string}
+ */
+function rewriteLocalhostUploads(p) {
+  const flag = envStr('VITE_REWRITE_LOCALHOST_UPLOADS')
+  if (flag !== '1' && flag.toLowerCase() !== 'true') return p
+  if (!/^https?:\/\//i.test(p)) return p
+  const base = getUploadsAssetBase() || getFileServerOriginFromEnv()
+  if (!base) return p
+  try {
+    const u = new URL(p, 'http://localhost/')
+    if (!/^localhost$|^127\.0\.0\.1$/i.test(u.hostname)) return p
+    if (!/^\/?uploads\//i.test(u.pathname)) return p
+    const tail = (u.pathname + (u.search || '')).replace(/^\//, '')
+    return `${base.replace(/\/$/, '')}/${tail}`
+  } catch {
+    return p
+  }
+}
+
+/** uploads/ 或 \uploads\ 统一为 /uploads/,避免路由较深时相对路径错解析 */
+function normalizeUploadsPath(p) {
+  let s = String(p).replace(/\\/g, '/').trim()
+  if (/^uploads\//i.test(s) && !s.startsWith('/')) s = `/${s}`
+  return s
+}
+
+/**
+ * 展示用图片地址:与接口 material_url 等字段一致。
+ * 若仅返回 uploads 相对路径:可配 VITE_OSS_ASSET_BASE 或 VITE_UPLOADS_PUBLIC_ORIGIN 拼成可访问的完整 URL;
+ * 对误返回 localhost 的整链可配 VITE_REWRITE_LOCALHOST_UPLOADS=1 并配好上述基座或 VITE_BASE_PATH + VITE_UPLOADS_PORT。
  * @param {string|null|undefined} path
  * @returns {string}
  */
 export function displayImageUrl(path) {
   if (path == null || typeof path !== 'string') return ''
-  const p = path.trim()
+  let p = path.trim().replace(/\\/g, '/')
   if (!p) return ''
   if (p.startsWith('data:') || p.startsWith('blob:')) return p
-  if (p.startsWith('http://') || p.startsWith('https://') || p.startsWith('//')) return p
+  p = rewriteFullUrlUploadsToOss(p)
+  p = rewriteLocalhostUploads(p)
+  if (p.startsWith('//')) return p
+  if (p.startsWith('http://') || p.startsWith('https://')) return p
+  p = normalizeUploadsPath(p)
+  const uploadsBase = getUploadsAssetBase() || ( /^\/?uploads\//i.test(p) ? getOssBaseForFullUrlUploadsRewrite() : '' )
+  if (uploadsBase && /^\/?uploads\//i.test(p)) {
+    return `${uploadsBase.replace(/\/$/, '')}/${p.replace(/^\//, '')}`
+  }
   return p
 }
 
+/**
+ * 从同一条接口记录里已返回的完整图片地址(如 thumbnail_image、template_image_url)解析 origin,
+ * 用于给 page_image_urls 里仅含 /uploads/… 相对路径时与素材同域拼成可请求 URL(不依赖 .env)
+ * @param {string|null|undefined} refUrl
+ * @returns {string}
+ */
+export function getAssetOriginFromReferenceUrl(refUrl) {
+  if (refUrl == null || typeof refUrl !== 'string') return ''
+  const t = refUrl.trim()
+  if (!t.startsWith('http://') && !t.startsWith('https://')) return ''
+  try {
+    return new URL(t).origin
+  } catch {
+    return ''
+  }
+}
+
+/** 同条里缩略/封面可能是 http(s) 全链,也可能是与 page 同仓储的 /uploads 相对路径,先经 displayImageUrl 再取 origin */
+function getUploadsOriginFromReferencePath(referenceUrl) {
+  if (referenceUrl == null || referenceUrl === '') return ''
+  const raw = String(referenceUrl).trim()
+  if (!raw) return ''
+  const fromDirect = getAssetOriginFromReferenceUrl(raw)
+  if (fromDirect) return fromDirect
+  const full = displayImageUrl(raw)
+  if (full && (full.startsWith('http://') || full.startsWith('https://') || full.startsWith('//'))) {
+    const u = full.startsWith('//') ? `https:${full}` : full
+    return getAssetOriginFromReferenceUrl(u)
+  }
+  return ''
+}
+
+/**
+ * 先走 displayImageUrl;若未配 VITE_OSS_ASSET_BASE 等,结果可能仍是 /uploads/… 相对路径,
+ * 此时用 referenceUrl(同条目的缩略图完整链)补成与接口物料同一 OSS 域名的绝对地址。
+ * @param {string|null|undefined} path
+ * @param {string|null|undefined} [referenceUrl]
+ * @returns {string}
+ */
+export function displayImageUrlWithReference(path, referenceUrl) {
+  const out = displayImageUrl(path)
+  if (!out) return ''
+  if (out.startsWith('data:') || out.startsWith('blob:')) return out
+  if (out.startsWith('//')) return out
+  if (out.startsWith('http://') || out.startsWith('https://')) return out
+  if (!/^\/?uploads\//i.test(out)) return out
+  const origin = getUploadsOriginFromReferencePath(referenceUrl)
+  if (origin) {
+    return `${origin.replace(/\/$/, '')}/${out.replace(/^\//, '')}`
+  }
+  const fromEnv = getUploadsAssetBase()
+  if (fromEnv) {
+    return `${fromEnv.replace(/\/$/, '')}/${out.replace(/^\//, '')}`
+  }
+  return `${FALLBACK_OSS_ASSET_BASE.replace(/\/$/, '')}/${out.replace(/^\//, '')}`
+}
+
 /**
  * 素材库网格缩略图:对阿里云 OSS 地址追加 image/resize,避免侧栏同时拉 30 张原图导致极慢。
  * 非 OSS / 已有 x-oss-process / data 链原样返回。

+ 89 - 0
src/utils/stitchPageImages.js

@@ -0,0 +1,89 @@
+/**
+ * 多页整图按顺序上下拼接为一张,用于点击放大时展示「长图」而非多图切换
+ * @param {string[]} urls
+ * @returns {Promise<string|null>} data URL 或单张时原址
+ */
+export async function stitchPageImagesToDataUrl(urls) {
+  if (!urls || !urls.length) return null
+  if (urls.length === 1) return urls[0]
+
+  const loadImage = (u) =>
+    new Promise((resolve, reject) => {
+      if (!u || u.startsWith('data:') || u.startsWith('blob:')) {
+        const img = new Image()
+        img.onload = () => resolve(img)
+        img.onerror = () => reject(new Error('load'))
+        img.src = u
+        return
+      }
+      const tryBlob = () => {
+        fetch(String(u), { mode: 'cors', credentials: 'omit', cache: 'no-store' })
+          .then((r) => {
+            if (!r.ok) throw new Error('fetch')
+            return r.blob()
+          })
+          .then((blob) => {
+            const o = URL.createObjectURL(blob)
+            const im = new Image()
+            im.onload = () => {
+              URL.revokeObjectURL(o)
+              resolve(im)
+            }
+            im.onerror = () => {
+              URL.revokeObjectURL(o)
+              reject(new Error('blobimg'))
+            }
+            im.src = o
+          })
+          .catch(() => reject(new Error('fetch-all')))
+      }
+      const img = new Image()
+      img.crossOrigin = 'anonymous'
+      img.onload = () => resolve(img)
+      img.onerror = () => tryBlob()
+      img.src = u
+    })
+
+  try {
+    const images = await Promise.all(urls.map((u) => loadImage(u)))
+    const w = Math.max(1, ...images.map((im) => im.naturalWidth || 1))
+    const scales = images.map((im) => w / (im.naturalWidth || 1))
+    const heights = images.map((im, i) => (im.naturalHeight || 0) * scales[i])
+    const totalH = Math.max(1, heights.reduce((a, b) => a + b, 0))
+    const maxSide = 8192
+    let outW = w
+    let outH = totalH
+    let scale2 = 1
+    if (w > maxSide || totalH > maxSide) {
+      scale2 = Math.min(maxSide / w, maxSide / totalH, 1)
+      outW = Math.round(w * scale2)
+      outH = Math.round(totalH * scale2)
+    }
+    const canvas = document.createElement('canvas')
+    canvas.width = outW
+    canvas.height = outH
+    const ctx = canvas.getContext('2d')
+    if (!ctx) return null
+    ctx.fillStyle = '#ffffff'
+    ctx.fillRect(0, 0, outW, outH)
+    let y = 0
+    images.forEach((im, i) => {
+      const rowH = heights[i] * scale2
+      ctx.drawImage(
+        im,
+        0,
+        0,
+        im.naturalWidth || 1,
+        im.naturalHeight || 1,
+        0,
+        y,
+        outW,
+        rowH
+      )
+      y += rowH
+    })
+    return canvas.toDataURL('image/png')
+  } catch {
+    return null
+  }
+}

+ 53 - 0
src/utils/templatePagePreviewUrls.js

@@ -0,0 +1,53 @@
+import { displayImageUrlWithReference } from '@/utils/displayImageUrl.js'
+
+/**
+ * 解析 product_template 的 page_image_urls / preview_images 等为可请求的 URL 列表(与列表多图条、拼接预览一致)
+ * @param {Record<string, unknown>} template
+ * @returns {string[]}
+ */
+export function getPagePreviewUrls(template) {
+  const raw =
+    template?.page_image_urls ??
+    template?.pageImageUrls ??
+    template?.preview_image_urls ??
+    template?.previewImageUrls
+  let arr
+  if (raw == null || raw === '') {
+    const prev = template?.preview_images ?? template?.previewImages
+    if (Array.isArray(prev) && prev.length) {
+      const onlyPaths = prev.filter((x) => {
+        const s = x == null ? '' : String(x).trim()
+        if (!s || s.length > 1_000_000) return false
+        if (s.startsWith('data:') || s.startsWith('blob:')) return false
+        return true
+      })
+      arr = onlyPaths.length ? onlyPaths : null
+    } else {
+      return []
+    }
+  } else if (Array.isArray(raw)) {
+    arr = raw
+  } else if (typeof raw === 'string') {
+    const t = raw.trim()
+    if (!t || t === '[]') return []
+    try {
+      arr = JSON.parse(t)
+    } catch {
+      return []
+    }
+  } else {
+    return []
+  }
+  if (!Array.isArray(arr) || !arr.length) return []
+  const ref = template?.thumbnail_image || template?.template_image_url
+  const mapped = arr
+    .map((p) => {
+      if (p == null || String(p).trim() === '') return ''
+      return displayImageUrlWithReference(String(p).trim(), ref)
+    })
+    .filter(Boolean)
+  const httpOnly = mapped.filter(
+    (u) => u.startsWith('http://') || u.startsWith('https://') || u.startsWith('//')
+  )
+  return httpOnly.length ? httpOnly : []
+}

+ 74 - 0
src/utils/templatePromptPages.js

@@ -0,0 +1,74 @@
+/**
+ * 多图模版里 chinese_description 常为 JSON 数组(每页一条字符串,或与 CreateTemplate 一致的结构)
+ */
+
+function itemToPromptString(item) {
+  if (item == null) return ''
+  if (typeof item === 'string') return item
+  if (typeof item === 'object') {
+    if (Object.prototype.hasOwnProperty.call(item, '产品名称') && item['产品名称'] != null) {
+      return String(item['产品名称'])
+    }
+    try {
+      return JSON.stringify(item)
+    } catch {
+      return String(item)
+    }
+  }
+  return String(item)
+}
+
+/**
+ * 接口/表单里的整段描述解析为「每页一条」
+ * @param {unknown} raw
+ * @returns {string[]}
+ */
+export function parseTemplateChineseDescriptionToPages(raw) {
+  if (raw == null) return ['']
+  if (Array.isArray(raw)) {
+    if (raw.length === 0) return ['']
+    return raw.map((x) => itemToPromptString(x))
+  }
+  const s = String(raw).trim()
+  if (s === '') return ['']
+  if (s.startsWith('[') || s.startsWith('{')) {
+    try {
+      const j = JSON.parse(s)
+      if (Array.isArray(j)) {
+        if (j.length === 0) return ['']
+        return j.map((x) => itemToPromptString(x))
+      }
+    } catch {
+      // 非合法 JSON 则当作单段
+    }
+  }
+  return [s]
+}
+
+/**
+ * 多页时序列化为 JSON 数组字符串;单页为纯文本
+ * @param {string[]} pages
+ * @returns {string}
+ */
+export function serializePagePrompts(pages) {
+  const list = Array.isArray(pages) && pages.length
+    ? pages.map((p) => (p == null ? '' : String(p)))
+    : ['']
+  const allEmpty = list.every((s) => !String(s).trim())
+  if (allEmpty) return ''
+  if (list.length === 1) return list[0] || ''
+  return JSON.stringify(list)
+}
+
+const NUMS = '一二三四五六七八九十'
+
+/**
+ * 图一、图二 …
+ * @param {number} index 从 0 起
+ * @returns {string}
+ */
+export function promptPageLabel(index) {
+  if (index < 0) return '图'
+  if (index < 10) return `图${NUMS[index]}`
+  return `图${index + 1}`
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 509 - 184
src/view/Product/ProductTemplateReplace.vue


+ 22 - 22
src/view/Product/Shop.vue

@@ -434,30 +434,30 @@ const userForm = ref(null)
 const enterAddUserDialog = async() => {
   userInfo.value.createName = userStore.userInfo.nickName
   userInfo.value.createCode = userStore.userInfo.userName
-  userForm.value.validate(async valid => {
-    if (valid) {
-      const req = {
-        ...userInfo.value
+  userForm.value.validate(async (valid) => {
+    if (!valid) return
+    const req = { ...userInfo.value }
+    if (dialogFlag.value === 'add') {
+      const res = await merchantAdd(req)
+      if (res.code === 0) {
+        ElMessage({ type: 'success', message: '创建成功' })
+        await getTableData()
+        closeAddUserDialog()
+      } else {
+        ElMessage({ type: 'error', message: res.msg || '创建失败' })
       }
-      if (dialogFlag.value === 'add') {
-        console.log('创建用户', req)
-
-        const res = await merchantAdd(req)
-        if (res.code === 0) {
-          ElMessage({ type: 'success', message: '创建成功' })
-          await getTableData()
-          closeAddUserDialog()
-        }
+    } else if (dialogFlag.value === 'edit') {
+      if (req.id === undefined || req.id === null) {
+        ElMessage.error('缺少商户 id,无法保存')
+        return
       }
-      if (dialogFlag.value === 'edit') {
-        console.log('编辑用户', req)
-        return;
-        const res = await merchantEdit(req)
-        if (res.code === 0) {
-          ElMessage({ type: 'success', message: '编辑成功' })
-          await getTableData()
-          closeAddUserDialog()
-        }
+      const res = await merchantEdit(req)
+      if (res.code === 0) {
+        ElMessage({ type: 'success', message: '编辑成功' })
+        await getTableData()
+        closeAddUserDialog()
+      } else {
+        ElMessage({ type: 'error', message: res.msg || '编辑失败' })
       }
     }
   })

+ 314 - 126
src/view/TemplateManagement/CreateTemplate.vue

@@ -183,9 +183,6 @@
             class="canvas"
             :style="canvasFitCanvasStyle"
             @mousedown="handleCanvasMouseDown"
-            @mousemove="handleCanvasMouseMove"
-            @mouseup="handleCanvasMouseUp"
-            @mouseleave="handleCanvasMouseUp"
             @wheel="handleCanvasWheel"
             @contextmenu.prevent
           >
@@ -264,6 +261,16 @@
         </div>
         <!-- 图层管理 -->
         <div v-show="rightPanelTab === 'layer'" class="right-section">
+          <div class="page-prompt-block">
+            <div class="page-prompt-label">第 {{ currentPageIndex + 1 }} 页 · 提示词</div>
+            <el-input
+              v-model="currentPageChineseDescription"
+              type="textarea"
+              :rows="8"
+              maxlength="2000"
+              show-word-limit
+            />
+          </div>
           <div class="layer-actions">
             <el-button size="small" @click="moveLayerUp" :disabled="!canMoveUp">
               <el-icon><ArrowUp /></el-icon>
@@ -295,63 +302,69 @@
               @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 class="layer-item-card-upper">
+                <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="" loading="lazy" />
+                  <div v-else class="layer-card-thumb img-placeholder"><el-icon><Picture /></el-icon></div>
+                </template>
+                <div class="layer-item-card-toolbar">
+                  <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>
-              </template>
-              <template v-else>
-                <img v-if="layer.url" :src="getImageLoadUrl(layer.url)" class="layer-card-thumb" alt="" loading="lazy" />
-                <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="(v) => !v && (layerNameEditingId = 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="layerNameEditingId = null"
-                      @keydown.enter.prevent="layerNameEditingId = null"
-                    />
+              </div>
+              <div class="layer-item-card-lower" draggable="false" @dragstart.stop>
+                <div class="layer-card-name-wrap">
+                  <div class="layer-card-name-trigger">
+                    <el-popover
+                      :visible="layerNameEditingId === layer.id"
+                      @update:visible="(v) => !v && (layerNameEditingId = null)"
+                      trigger="manual"
+                      placement="bottom-start"
+                      :width="280"
+                      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="layerNameEditingId = null"
+                          @keydown.enter.prevent="layerNameEditingId = null"
+                        />
+                      </div>
+                    </el-popover>
+                    <el-icon class="layer-card-name-edit-icon" title="编辑名称" @click.stop="openLayerNameEdit(layer)"><EditPen /></el-icon>
                   </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 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>
@@ -668,63 +681,67 @@
     <el-dialog
       v-model="previewDialogVisible"
       title="预览模版"
-      width="560px"
+      width="480px"
       align-center
       class="template-preview-dialog"
+      append-to-body
       destroy-on-close
     >
-      <div class="template-preview-dialog-body">
-        <div class="template-preview-dom-body">
-          <div
-            v-for="(page, pIdx) in pages"
-            :key="page.id"
-            class="template-preview-page-block"
-          >
-            <div class="template-preview-page-title">第 {{ pIdx + 1 }} 页</div>
+      <!-- 外壳与 TemplateDesign「预览模版」弹层同构:inner → viewport(clamp 高) → scroller,多页 DOM 在 scroller 内纵向排布 -->
+      <div class="template-strip-preview-inner">
+        <div class="template-strip-preview-viewport">
+          <div class="template-strip-preview-scroller">
             <div
-              class="template-preview-page-scale"
-              :style="{
-                width: previewScaledSize.w + 'px',
-                height: previewScaledSize.h + 'px'
-              }"
+              v-for="(page, pIdx) in pages"
+              :key="page.id"
+              class="template-preview-page-block"
             >
+              <div class="template-preview-page-title">第 {{ pIdx + 1 }} 页</div>
               <div
-                class="template-preview-page-canvas"
+                class="template-preview-page-scale"
                 :style="{
-                  width: canvasWidth + 'px',
-                  height: canvasHeight + 'px',
-                  transform: `scale(${previewDialogScale})`,
-                  transformOrigin: 'top left'
+                  width: previewScaledSize.w + 'px',
+                  height: previewScaledSize.h + 'px'
                 }"
               >
                 <div
-                  v-for="layer in page.layers"
-                  :key="layer.id"
-                  v-show="layer.visible"
-                  class="layer template-preview-layer"
-                  :class="{
-                    'text-layer': layer.type === 'text',
-                    'shape-layer-wrap': layer.type === 'shape'
+                  class="template-preview-page-canvas"
+                  :style="{
+                    width: canvasWidth + 'px',
+                    height: canvasHeight + 'px',
+                    transform: `scale(${previewDialogScale})`,
+                    transformOrigin: 'top left'
                   }"
-                  :style="getLayerStyle(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"
-                      decoding="async"
-                      referrerpolicy="no-referrer"
-                    />
-                  </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)">
-                      {{ layer.text }}
-                    </div>
-                  </template>
+                  <div
+                    v-for="layer in page.layers"
+                    :key="layer.id"
+                    v-show="layer.visible"
+                    class="layer template-preview-layer"
+                    :class="{
+                      'text-layer': layer.type === 'text',
+                      'shape-layer-wrap': layer.type === 'shape'
+                    }"
+                    :style="getLayerStyle(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"
+                        decoding="async"
+                        referrerpolicy="no-referrer"
+                      />
+                    </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)">
+                        {{ layer.text }}
+                      </div>
+                    </template>
+                  </div>
                 </div>
               </div>
             </div>
@@ -765,7 +782,8 @@ import {
   MATERIAL_NAME_MAX_LEN,
   DEFAULT_CANVAS_RATIO,
   DEFAULT_CANVAS_WIDTH,
-  DEFAULT_CANVAS_HEIGHT
+  DEFAULT_CANVAS_HEIGHT,
+  DEFAULT_PAGE_CHINESE_DESCRIPTION
 } from './constants.js'
 import {
   resolveMaterialUrl,
@@ -777,6 +795,7 @@ import {
   tryGetCanvasDomDrawableForLayer,
   canvasLayerImgAttrs
 } from './utils.js'
+import { inferPromptKeyFromTextLayerName, mergePromptFromTextLayers } from './promptFieldSync.js'
 import AddTabPane from './components/AddTabPane.vue'
 import AiTabPane from './components/AiTabPane.vue'
 import MaterialTabPane from './components/MaterialTabPane.vue'
@@ -872,11 +891,13 @@ const canvasFitCanvasStyle = computed(() => ({
   transformOrigin: 'top left'
 }))
 
-/** 预览弹窗 DOM 复刻用:整页等比缩小,宽度适配对话框(与左侧页缩略图一致不依赖 canvas 截图) */
+/** 弹窗内整页等比缩放的显示宽度(与侧栏页缩、列表缩略同量级,不改变画布 9:16 比例) */
+const previewListMaxWidthPx = 360
+
+/** 预览弹窗:按该宽度对画布等比 scale,不套列表的 4/3.5 裁切 */
 const previewDialogScale = computed(() => {
-  const maxW = 520
   const w = canvasWidth.value || 1
-  return Math.min(1, maxW / w)
+  return Math.min(1, previewListMaxWidthPx / w)
 })
 const previewScaledSize = computed(() => {
   const s = previewDialogScale.value
@@ -944,9 +965,19 @@ console.log('获取用户名称',userStore.userInfo.nickName)
 // 多页面:每页独立图层,类似 PowerPoint
 let pageIdCounter = 0
 const pages = ref([
-  { id: ++pageIdCounter, layers: [] }
+  { id: ++pageIdCounter, layers: [], chineseDescription: DEFAULT_PAGE_CHINESE_DESCRIPTION }
 ])
 const currentPageIndex = ref(0)
+// 当前画布页「提示词」,与第几页一一对应,保存时随 pages 顺序写入 chinese_description
+const currentPageChineseDescription = computed({
+  get() {
+    return pages.value[currentPageIndex.value]?.chineseDescription ?? ''
+  },
+  set(v) {
+    const p = pages.value[currentPageIndex.value]
+    if (p) p.chineseDescription = v == null ? '' : String(v)
+  }
+})
 // layers 始终指向当前页的 layers 数组引用,切换页时同步
 const layers = ref(pages.value[0].layers)
 const selectedLayerId = ref(null)
@@ -1025,7 +1056,9 @@ function mapRowToLayer(row) {
     originalHeight: height,
     zIndex: Number(row.z_index || 0) || 0,
     materialId: row.material_id,
-    templateId: row.template_id
+    templateId: row.template_id,
+    promptSyncKey:
+      row.layer_type === 'text' ? (inferPromptKeyFromTextLayerName(row.layer_name) || undefined) : undefined
   }
 }
 
@@ -1037,7 +1070,7 @@ const syncLayersToCurrentPage = () => {
 
 // 添加新页面
 const addPage = () => {
-  pages.value.push({ id: ++pageIdCounter, layers: [] })
+  pages.value.push({ id: ++pageIdCounter, layers: [], chineseDescription: DEFAULT_PAGE_CHINESE_DESCRIPTION })
   currentPageIndex.value = pages.value.length - 1
   syncLayersToCurrentPage()
   selectedLayerId.value = null
@@ -1069,21 +1102,48 @@ const clearDesign = () => {
   editingTemplateId.value = null
   templateName.value = '未命名模版'
   pageIdCounter = 0
-  pages.value = [{ id: ++pageIdCounter, layers: [] }]
+  pages.value = [{ id: ++pageIdCounter, layers: [], chineseDescription: DEFAULT_PAGE_CHINESE_DESCRIPTION }]
   currentPageIndex.value = 0
   syncLayersToCurrentPage()
   selectedLayerId.value = null
 }
 
+/** 从接口页对象上取每页提示词 */
+function pickPageChineseDescription(p) {
+  if (p == null) return ''
+  const v = p.chinese_description ?? p.chineseDescription
+  return typeof v === 'string' ? v : v != null ? String(v) : ''
+}
+
 // 解析接口数据为 pages 结构:支持 data.pages、layers 带 page_index、或平铺单页
 function parseDataToPages(data) {
+  const zipDescriptions = (out) => {
+    const arr = data?.chinese_description ?? data?.chineseDescription
+    if (!Array.isArray(arr) || !out.length) return out
+    out.forEach((p, i) => {
+      if (p.chineseDescription && String(p.chineseDescription).trim()) return
+      const d = arr[i]
+      if (d == null || d === '') return
+      p.chineseDescription = typeof d === 'string' ? d : String(d)
+    })
+    return out
+  }
   // 1) 后端直接返回 pages 数组
   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)
-    }))
+    const topDesc = data?.chinese_description ?? data?.chineseDescription
+    return data.pages.map((p, i) => {
+      let desc = pickPageChineseDescription(p)
+      if ((!desc || !String(desc).trim()) && Array.isArray(topDesc) && topDesc[i] != null && topDesc[i] !== '') {
+        const d = topDesc[i]
+        desc = typeof d === 'string' ? d : String(d)
+      }
+      return {
+        id: ++pageIdCounter,
+        layers: (Array.isArray(p?.layers) ? p.layers : []).map(mapRowToLayer),
+        chineseDescription: desc || ''
+      }
+    })
   }
   let rows = Array.isArray(data) ? data : (data?.layers ?? data?.data ?? [])
   if (!Array.isArray(rows) || !rows.length) return null
@@ -1099,15 +1159,31 @@ function parseDataToPages(data) {
     }
     const sorted = [...byPage.entries()].sort((a, b) => a[0] - b[0])
     pageIdCounter = 0
-    return sorted.map(([, pRows]) => ({
+    const out = sorted.map(([, pRows]) => ({
       id: ++pageIdCounter,
-      layers: pRows.map(mapRowToLayer)
+      layers: pRows.map(mapRowToLayer),
+      chineseDescription: ''
     }))
+    return zipDescriptions(out)
   }
 
   // 3) 平铺单页
   pageIdCounter = 0
-  return [{ id: ++pageIdCounter, layers: rows.map(mapRowToLayer) }]
+  let desc0 = ''
+  const top = data?.chinese_description ?? data?.chineseDescription
+  if (Array.isArray(top) && top.length && top[0] != null) {
+    desc0 = typeof top[0] === 'string' ? top[0] : String(top[0])
+  } else if (typeof data?.chinese_description === 'string') {
+    desc0 = data.chinese_description
+  } else if (typeof data?.chineseDescription === 'string') {
+    desc0 = data.chineseDescription
+  }
+  const one = {
+    id: ++pageIdCounter,
+    layers: rows.map(mapRowToLayer),
+    chineseDescription: desc0
+  }
+  return [one]
 }
 
 // 使用模板:通过模板 id 获取模板关联的图层数据并还原到画布(预览后可编辑并保存修改)
@@ -1403,6 +1479,20 @@ const addShapeLayer = (preset) => {
   selectedLayerId.value = newLayer.id
 }
 
+/** 根据当前页文字图层内容,更新右侧「提示词」中 产品名称/标题/副标题/内容/背景图 对应行 */
+const syncCurrentPagePromptFromTextLayers = () => {
+  const page = pages.value[currentPageIndex.value]
+  if (!page) return
+  const next = mergePromptFromTextLayers(
+    page.chineseDescription,
+    layers.value,
+    DEFAULT_PAGE_CHINESE_DESCRIPTION
+  )
+  if (next !== page.chineseDescription) {
+    page.chineseDescription = next
+  }
+}
+
 // 添加文字图层,preset 可选 'title' | 'subtitle' | 'body'(TEXT_PRESETS 来自 constants.js)
 const addTextLayer = (preset) => {
   const def = preset ? TEXT_PRESETS[preset] : { fontSize: 16, fontWeight: 'normal', text: '双击编辑文字', name: '文字' }
@@ -1433,12 +1523,31 @@ const addTextLayer = (preset) => {
     lineHeight: 1.5,
     letterSpacing: 0
   }
+  if (preset === 'title') newLayer.promptSyncKey = 'title'
+  else if (preset === 'subtitle') newLayer.promptSyncKey = 'subtitle'
+  else if (preset === 'body') newLayer.promptSyncKey = 'content'
 
   layers.value.push(newLayer)
   selectedLayerId.value = newLayer.id
   textLayerCount.value++
+  nextTick(() => {
+    syncCurrentPagePromptFromTextLayers()
+  })
 }
 
+watch(
+  layers,
+  () => {
+    syncCurrentPagePromptFromTextLayers()
+  },
+  { deep: true }
+)
+watch(currentPageIndex, () => {
+  nextTick(() => {
+    syncCurrentPagePromptFromTextLayers()
+  })
+})
+
 const textLayerCount = ref(0)
 
 // 素材库状态
@@ -1998,6 +2107,7 @@ onDeactivated(() => {
 
 onUnmounted(() => {
   teardownDesignSurface()
+  unbindWindowCanvasInteraction()
   uploadPreviewObjectUrls.value.forEach((u) => URL.revokeObjectURL(u))
   uploadPreviewObjectUrls.value = []
 })
@@ -2053,15 +2163,32 @@ const saveTemplate = async () => {
     return
   }
 
-  // 封面 previewImage(Base64):由离屏 canvas + generateCanvasPreview 生成,不是「预览模版」弹窗的截图。
-  // 弹窗用 DOM+img 仅展示,不要求 OSS CORS;导出需未污染像素:OSS 配 CORS + 可选 VITE_CANVAS_IMG_CROSS_ORIGIN=1,或 VITE_PREVIEW_IMAGE_PROXY,否则 fetch/DOM 均无法带齐素材。
-  const previewLayers = (() => {
-    const cur = pages.value[currentPageIndex.value]?.layers
-    if (cur?.length) return cur
-    const p = pages.value.find((pg) => pg?.layers?.length)
-    return p?.layers ?? pages.value[0]?.layers ?? []
-  })()
-  const previewImage = await generateCanvasPreview(previewLayers.length ? previewLayers : undefined)
+  syncTemplateNameFromEl()
+  const nameTrim = (templateName.value || '').trim()
+  if (!nameTrim || nameTrim === '未命名模版') {
+    ElMessage.warning('请先为模版命名,不能保存为「未命名模版」')
+    focusTemplateNameEl()
+    return
+  }
+
+  // 多页:每页一张 data URL,与 pages / chinese_description 下标一一对应;主封面 previewImage=第1页,失败时再按「首屏有图」重导一次。
+  const previewImages = await Promise.all(
+    pages.value.map(p =>
+      generateCanvasPreview(
+        p?.layers && p.layers.length ? p.layers : []
+      )
+    )
+  )
+  let previewImage = previewImages[0] ?? null
+  if (!previewImage) {
+    const previewLayers = (() => {
+      const cur = pages.value[currentPageIndex.value]?.layers
+      if (cur?.length) return cur
+      const p = pages.value.find((pg) => pg?.layers?.length)
+      return p?.layers ?? pages.value[0]?.layers ?? []
+    })()
+    previewImage = await generateCanvasPreview(previewLayers.length ? previewLayers : undefined)
+  }
 
   // 收集所有页的新上传素材图
   const uploadedMaterials = flatLayers
@@ -2076,12 +2203,17 @@ const saveTemplate = async () => {
     }))
 
   const templateData = {
-    template_name: templateName.value || '未命名模版',
+    template_name: nameTrim,
     sys_id: userStore.userInfo.nickName,
     canvasWidth: canvasWidth.value,
     canvasHeight: canvasHeight.value,
     canvasRatio: canvasRatio.value,
+    /** 主缩略/列表用:与第 1 页一致 */
     previewImage,
+    /** 多页时与 pages 下标一一对应;后端可落盘为多条路径并 JSON 入新字段,或只存 0/全部由后端定 */
+    preview_images: previewImages,
+    /** 与 pages 下标对齐,每页一条提示词 */
+    chinese_description: pages.value.map(p => (p.chineseDescription != null ? String(p.chineseDescription) : '')),
     uploaded_materials: uploadedMaterials,
     layers: flatLayers.map(layer => layerToApiShape(layer, layer._pageIndex ?? 0))
   }
@@ -2231,6 +2363,11 @@ const handleCanvasSizeChange = () => {
       }
     }
   }
+  for (const p of pages.value || []) {
+    for (const l of p?.layers || []) {
+      clampLayerToCanvasBounds(l)
+    }
+  }
 }
 
 // 图片宽度变化时,如果锁定比例,自动调整高度
@@ -2414,6 +2551,32 @@ let startRotation = 0
 let centerX = 0
 let centerY = 0
 
+let windowCanvasInteractionBound = false
+const onWindowPointerMove = (e) => {
+  if (!isDragging && !isResizing && !isRotating) return
+  handleCanvasMouseMove(e)
+}
+const onWindowPointerUp = () => {
+  if (!windowCanvasInteractionBound) return
+  handleCanvasMouseUp()
+}
+function bindWindowCanvasInteraction() {
+  if (windowCanvasInteractionBound) return
+  windowCanvasInteractionBound = true
+  window.addEventListener('mousemove', onWindowPointerMove, true)
+  window.addEventListener('mouseup', onWindowPointerUp, true)
+  window.addEventListener('pointerup', onWindowPointerUp, true)
+  window.addEventListener('pointercancel', onWindowPointerUp, true)
+}
+function unbindWindowCanvasInteraction() {
+  if (!windowCanvasInteractionBound) return
+  windowCanvasInteractionBound = false
+  window.removeEventListener('mousemove', onWindowPointerMove, true)
+  window.removeEventListener('mouseup', onWindowPointerUp, true)
+  window.removeEventListener('pointerup', onWindowPointerUp, true)
+  window.removeEventListener('pointercancel', onWindowPointerUp, true)
+}
+
 const handleLayerMouseDown = (e, layer) => {
   if (currentTool.value !== 'select' && currentTool.value !== 'move') return
   
@@ -2430,7 +2593,7 @@ const handleLayerMouseDown = (e, layer) => {
   dragStartY = e.clientY
   layerStartX = layer.x
   layerStartY = layer.y
-  
+  bindWindowCanvasInteraction()
   e.preventDefault()
 }
 
@@ -2451,6 +2614,21 @@ const getCanvasScale = () => {
   return { scaleX, scaleY }
 }
 
+/** 将图层位置限制在画布范围内:小于画布时贴边;大于画布时允许平移使内容仍覆盖可视区 */
+const clampLayerToCanvasBounds = (layer) => {
+  if (!layer || layer.locked) return
+  const cw = canvasWidth.value
+  const ch = canvasHeight.value
+  const w = Math.max(1, layer.width || 0)
+  const h = Math.max(1, layer.height || 0)
+  const minX = Math.min(0, cw - w)
+  const maxX = Math.max(0, cw - w)
+  const minY = Math.min(0, ch - h)
+  const maxY = Math.max(0, ch - h)
+  layer.x = Math.round(Math.min(maxX, Math.max(minX, layer.x ?? 0)))
+  layer.y = Math.round(Math.min(maxY, Math.max(minY, layer.y ?? 0)))
+}
+
 const handleCanvasMouseMove = (e) => {
   if (!selectedLayer.value) return
   
@@ -2462,6 +2640,7 @@ const handleCanvasMouseMove = (e) => {
     
     selectedLayer.value.x = Math.round(layerStartX + deltaX)
     selectedLayer.value.y = Math.round(layerStartY + deltaY)
+    clampLayerToCanvasBounds(selectedLayer.value)
   }
   
   if (isResizing) {
@@ -2515,6 +2694,7 @@ const handleCanvasMouseMove = (e) => {
     
     layer.width = Math.round(newWidth)
     layer.height = Math.round(newHeight)
+    clampLayerToCanvasBounds(layer)
   }
   
   if (isRotating) {
@@ -2529,9 +2709,13 @@ const handleCanvasMouseMove = (e) => {
 }
 
 const handleCanvasMouseUp = () => {
+  if (isDragging || isResizing) {
+    if (selectedLayer.value) clampLayerToCanvasBounds(selectedLayer.value)
+  }
   isDragging = false
   isResizing = false
   isRotating = false
+  unbindWindowCanvasInteraction()
 }
 
 const startResize = (e, direction) => {
@@ -2545,6 +2729,8 @@ const startResize = (e, direction) => {
   startHeight = selectedLayer.value.height
   layerStartX = selectedLayer.value.x
   layerStartY = selectedLayer.value.y
+  bindWindowCanvasInteraction()
+  e.preventDefault()
 }
 
 const startRotate = (e) => {
@@ -2554,6 +2740,8 @@ const startRotate = (e) => {
   centerX = selectedLayer.value.x + selectedLayer.value.width / 2
   centerY = selectedLayer.value.y + selectedLayer.value.height / 2
   startRotation = selectedLayer.value.rotation
+  bindWindowCanvasInteraction()
+  e.preventDefault()
 }
 
 const handleCanvasWheel = (e) => {

+ 100 - 9
src/view/TemplateManagement/TemplateDesign.vue

@@ -47,7 +47,7 @@
           </button>
         </nav>
 
-        <!-- 我的设计中:按发布状态筛选:全部 / 已发布 / 未发布 -->
+        <!-- 我的设计中:全部 / 详情图(多页) / 已发布 / 未发布 -->
         <nav v-if="libraryMenuActive === 'myWorks'" class="publish-filter-bar">
           <button
             type="button"
@@ -57,6 +57,14 @@
           >
             全部
           </button>
+          <button
+            type="button"
+            class="publish-filter-item"
+            :class="{ active: publishFilter === 'detail' }"
+            @click="publishFilter = 'detail'"
+          >
+            详情图
+          </button>
           <button
             type="button"
             class="publish-filter-item"
@@ -95,7 +103,23 @@
                 class="template-item"
               >
                 <div class="template-preview" @click="openPreview(template)">
+                <div
+                  v-if="getPagePreviewUrls(template).length"
+                  class="template-preview-strip"
+                >
+                  <img
+                    v-for="(u, i) in getPagePreviewUrls(template)"
+                    :key="i"
+                    :src="u"
+                    :alt="`${template.template_name || '模版'}-${i + 1}`"
+                    loading="lazy"
+                    decoding="async"
+                    class="template-preview-strip-item"
+                    @error="(e) => { e.target.onerror = null; e.target.style.visibility = 'hidden' }"
+                  />
+                </div>
                 <img
+                  v-else
                   :src="formatImageUrl(template.thumbnail_image || template.template_image_url)"
                   :alt="template.template_name"
                   loading="lazy"
@@ -157,12 +181,14 @@
             </div>
           </div>
         </section>
-        <!-- 模版图片放大预览 -->
+        <!-- 单页/多页:多页时先纵向拼成一张再进 viewer,url-list 仅一条故无左右切页、只有缩放等 -->
         <el-image-viewer
           v-if="previewVisible"
-          :url-list="previewImageUrl ? [previewImageUrl] : []"
+          :key="previewViewerKey"
+          :url-list="previewImageUrlList"
           :hide-on-click-modal="true"
-          @close="previewVisible = false"
+          :initial-index="0"
+          @close="onPreviewClose"
         />
       </div>
     </template>
@@ -184,12 +210,14 @@
 <script setup>
 import { ref, computed, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
 import { useRoute } from 'vue-router'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElLoading } from 'element-plus'
 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'
 import CreateTemplate from './CreateTemplate.vue'
+import { getPagePreviewUrls } from '@/utils/templatePagePreviewUrls.js'
+import { stitchPageImagesToDataUrl } from '@/utils/stitchPageImages.js'
 import { resolveMaterialUrl } from './utils.js'
 
 const currentView = ref('library')
@@ -197,7 +225,7 @@ const templates = ref([])
 const templatesLoading = ref(false)
 const templateSearch = ref('')
 const libraryMenuActive = ref('myWorks')
-const publishFilter = ref('all') // 'all' | 'published' | 'unpublished',仅在「我的设计」中生效
+const publishFilter = ref('all') // 'all' | 'detail' | 'published' | 'unpublished',仅在「我的设计」中生效
 const page = ref(1)
 const pageSize = ref(30) // 每页条数(默认 30)
 const total = ref(0)
@@ -205,6 +233,10 @@ const publishLoading = ref(null)
 const deleteLoading = ref(null)
 const previewVisible = ref(false)
 const previewImageUrl = ref('')
+/** 始终单图:多页为拼接后的 dataURL,故 viewer 不展示左右切页 */
+const previewImageUrlList = computed(() => (previewImageUrl.value ? [previewImageUrl.value] : []))
+const previewViewerKey = ref(0)
+
 const editTemplate = ref(null)
 const editMode = ref('create')
 /** 每次「创建模版」递增,避免与 keep-alive 下同 key 复用旧空白画布 */
@@ -222,10 +254,15 @@ const route = useRoute()
 
 const isPublished = (template) => template?.release === 1 || template?.release === '1'
 
-// 按发布状态筛选后的列表(仅在「我的设计」时生效)
+// 按发布状态 / 详情图(多页) 筛选(仅在「我的设计」时生效)
+const isDetailViewTemplate = (t) => getPagePreviewUrls(t).length > 1
+
 const filteredTemplates = computed(() => {
   if (libraryMenuActive.value !== 'myWorks') return templates.value
   if (publishFilter.value === 'all') return templates.value
+  if (publishFilter.value === 'detail') {
+    return templates.value.filter((t) => isDetailViewTemplate(t))
+  }
   return templates.value.filter((t) => {
     const pub = isPublished(t)
     return publishFilter.value === 'published' ? pub : !pub
@@ -305,10 +342,43 @@ const doSearch = () => {
   fetchTemplates()
 }
 
-const openPreview = (template) => {
+const onPreviewClose = () => {
+  previewVisible.value = false
+  previewImageUrl.value = ''
+}
+
+const openPreview = async (template) => {
+  const pageUrls = getPagePreviewUrls(template)
+  if (pageUrls.length > 1) {
+    previewVisible.value = false
+    const loading = ElLoading.service({ lock: true, text: '正在拼接多页为长图...', background: 'rgba(0,0,0,0.15)' })
+    try {
+      const stitched = await stitchPageImagesToDataUrl(pageUrls)
+      if (stitched) {
+        previewImageUrl.value = stitched
+        previewViewerKey.value += 1
+        previewVisible.value = true
+      } else {
+        ElMessage.warning('多页图拼接失败,可检查资源地址或图片跨域(CORS)')
+        previewImageUrl.value = pageUrls[0] || ''
+        previewViewerKey.value += 1
+        if (previewImageUrl.value) previewVisible.value = true
+      }
+    } finally {
+      loading.close()
+    }
+    return
+  }
+  if (pageUrls.length === 1) {
+    previewImageUrl.value = pageUrls[0]
+    previewViewerKey.value += 1
+    previewVisible.value = true
+    return
+  }
   const url = formatImageUrl(template.thumbnail_image || template.template_image_url)
   if (url) {
     previewImageUrl.value = url
+    previewViewerKey.value += 1
     previewVisible.value = true
   }
 }
@@ -601,7 +671,28 @@ onBeforeUnmount(() => {
   transform: translateY(0);
   box-shadow: 0 2px 6px rgba(0,0,0,0.1);
 }
-.template-preview { position: relative; aspect-ratio: 4/3.5; cursor: pointer; background: #f5f5f5; }
+.template-preview { position: relative; aspect-ratio: 4/3.5; cursor: pointer; background: #f5f5f5; overflow: hidden; }
+.template-preview-strip {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  width: 100%;
+  height: 100%;
+  overflow-x: hidden;
+  overflow-y: auto;
+  background: #fff;
+  scrollbar-width: thin;
+}
+.template-preview-strip-item {
+  width: 100%;
+  height: auto;
+  object-fit: contain;
+  object-position: top center;
+  display: block;
+  flex-shrink: 0;
+}
 .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;

+ 58 - 27
src/view/TemplateManagement/components/MaterialTabPane.vue

@@ -256,7 +256,7 @@ const emitMaterialPageChange = (p) => {
 .materials-panel {
   display: flex;
   flex-direction: column;
-  gap: 8px;
+  gap: 10px;
   min-height: 0;
   min-width: 0;
 }
@@ -309,61 +309,86 @@ const emitMaterialPageChange = (p) => {
   flex: 1 1 0;
   min-width: 0;
   position: relative;
-  display: flex;
+  display: block;
 }
 .materials-type-chips {
-  flex: 1 1 0;
+  width: 100%;
   min-width: 0;
   display: flex;
   flex-wrap: nowrap;
   gap: 6px;
+  align-items: center;
   overflow-x: auto;
+  overflow-y: hidden;
   padding: 4px 0;
-  min-height: 28px;
+  min-height: 32px;
+  box-sizing: border-box;
   scroll-behavior: smooth;
+  -webkit-overflow-scrolling: touch;
+  scrollbar-width: thin;
+  scrollbar-color: #dcdfe6 transparent;
 }
-.materials-type-chips-wrap::before,
-.materials-type-chips-wrap::after {
-  content: '';
+.materials-type-chips::-webkit-scrollbar { height: 3px; }
+.materials-type-chips::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 2px; }
+.chips-fade {
   position: absolute;
   top: 0;
   bottom: 0;
-  width: 12px;
+  width: 20px;
+  z-index: 1;
   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;
+.chips-fade-left {
+  left: 0;
+  background: linear-gradient(90deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0) 100%);
 }
-.materials-type-chips-wrap::after {
-  right: 34px;
-  background: linear-gradient(to left, rgba(255,255,255,0.95), transparent);
-  opacity: 0;
+.chips-fade-right {
+  right: 0;
+  background: linear-gradient(270deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0) 100%);
 }
-.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;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 12px;
+  min-height: 28px;
+  box-sizing: border-box;
   border-radius: 14px;
-  border: 1px solid transparent;
-  background: #f3f4f6;
+  border: 1px solid #e4e7ed;
+  background: #f5f7fa;
   color: #606266;
   font-size: 12px;
+  font-weight: 500;
+  line-height: 1.2;
   cursor: pointer;
-  max-width: 88px;
+  transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
+  max-width: 100px;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.materials-chip:hover { background: #eef2f7; }
+.materials-chip:hover {
+  background: #eef2f7;
+  border-color: #dcdfe6;
+  color: #303133;
+}
 .materials-chip.active {
   background: #ecf5ff;
-  border-color: #b3d8ff;
-  color: #409eff;
+  border-color: #a0cfff;
+  color: #1677ff;
+  font-weight: 600;
+  box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
+}
+.materials-chip:focus {
+  outline: none;
+}
+.materials-chip:focus-visible {
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.35);
+}
+.materials-chip.active:focus-visible {
+  box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2), 0 0 0 3px rgba(64, 158, 255, 0.25);
 }
 
 .materials-groups {
@@ -372,7 +397,13 @@ const emitMaterialPageChange = (p) => {
   gap: 10px;
   min-width: 0;
 }
-.materials-group-header { display: flex; align-items: center; justify-content: space-between; padding: 2px 2px 0; }
+.materials-group-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 2px 2px 4px;
+  margin-bottom: 2px;
+}
 .materials-group-title { font-size: 12px; color: #303133; font-weight: 600; }
 .materials-more {
   font-size: 12px;

+ 6 - 2
src/view/TemplateManagement/composables/useCanvasLayers.js

@@ -2,7 +2,7 @@
  * useCanvasLayers - Canvas, page, layer state and methods for CreateTemplate
  */
 import { ref, computed, reactive, nextTick } from 'vue'
-import { resolveMaterialUrl, getImageLoadUrl, toStoragePath, loadImageForCanvasExport } from '../utils.js'
+import { resolveMaterialUrl, getImageLoadUrl, toStoragePath, loadImageForCanvasExport, tryGetCanvasDomDrawableForLayer } from '../utils.js'
 import {
   SHAPE_PRESETS,
   TEXT_PRESETS,
@@ -654,7 +654,11 @@ export function useCanvasLayers() {
 
       const imageLayers = skipRaster ? [] : targetLayers.filter(l => (l.type || 'image') !== 'text' && l.url)
       for (const layer of imageLayers) {
-        layer._previewImg = await loadImageForCanvasExport(layer.url)
+        let drawable = tryGetCanvasDomDrawableForLayer(layer.id, canvasRef.value)
+        if (!drawable) {
+          drawable = await loadImageForCanvasExport(layer.url)
+        }
+        layer._previewImg = drawable
       }
 
       const layersToDraw = [...targetLayers]

+ 33 - 9
src/view/TemplateManagement/composables/useTemplateData.js

@@ -164,15 +164,34 @@ export function useTemplateData(props, emit, canvasLayers, materials) {
       ElMessage.warning('请先添加图片或文字,再生成模版')
       return
     }
-    const previewLayers = (() => {
-      const cur = pages.value[currentPageIndex.value]?.layers
-      if (cur?.length) return cur
-      const p = pages.value.find((pg) => pg?.layers?.length)
-      return p?.layers ?? pages.value[0]?.layers ?? []
-    })()
-    const previewImage = await generateCanvasPreview(previewLayers.length ? previewLayers : undefined)
+
+    syncTemplateNameFromEl()
+    const nameTrim = (templateName.value || '').trim()
+    if (!nameTrim || nameTrim === '未命名模版') {
+      ElMessage.warning('请先为模版命名,不能保存为「未命名模版」')
+      focusTemplateNameEl()
+      return
+    }
+
+    const previewImages = await Promise.all(
+      pages.value.map(p =>
+        generateCanvasPreview(
+          p?.layers && p.layers.length ? p.layers : []
+        )
+      )
+    )
+    let previewImage = previewImages[0] ?? null
+    if (!previewImage) {
+      const previewLayers = (() => {
+        const cur = pages.value[currentPageIndex.value]?.layers
+        if (cur?.length) return cur
+        const p = pages.value.find((pg) => pg?.layers?.length)
+        return p?.layers ?? pages.value[0]?.layers ?? []
+      })()
+      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:'))
+      .filter(l => (l.type !== 'text') && l.url && typeof l.url === 'string' && l.url.startsWith('data:'))
       .map(l => ({
         layer_id: l.id,
         name: l.name,
@@ -181,13 +200,18 @@ export function useTemplateData(props, emit, canvasLayers, materials) {
         type: l.materialType || '其他',
         Category_id: l.categoryId ?? l.Category_id ?? null
       }))
+
     const templateData = {
-      template_name: templateName.value || '未命名模版',
+      template_name: nameTrim,
       sys_id: userStore.userInfo.nickName,
       canvasWidth: canvasWidth.value,
       canvasHeight: canvasHeight.value,
       canvasRatio: canvasRatio.value,
       previewImage,
+      preview_images: previewImages,
+      chinese_description: pages.value.map(p =>
+        p.chineseDescription != null ? String(p.chineseDescription) : ''
+      ),
       uploaded_materials: uploadedMaterials,
       layers: flatLayers.map(layer => layerToApiShape(layer, layer._pageIndex ?? 0))
     }

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

@@ -21,6 +21,15 @@ export const PREVIEW_LIMIT = 3
 /** 素材库列表分页(与 Material_List 接口一致) */
 export const MATERIAL_PAGE_SIZE = 30
 
+/**
+ * 新建/新增页时「第 N 页 · 提示词」默认正文(保存为 chinese_description 对应项,可改)
+ */
+export const DEFAULT_PAGE_CHINESE_DESCRIPTION = `产品名称:
+标题:
+副标题:
+内容:
+背景图:`
+
 /** 画布比例配置 */
 export const RATIO_CONFIG = {
   '1:1': { width: 500, height: 500, ratio: 1 },

+ 95 - 0
src/view/TemplateManagement/promptFieldSync.js

@@ -0,0 +1,95 @@
+/**
+ * 将画布文字图层与「第 N 页 · 提示词」中的行(产品名称/标题/副标题/内容/背景图)对应同步
+ */
+
+/** 与提示词中「某:」行对应 key,与层上 promptSyncKey 一致 */
+export const PROMPT_LINE_KEYS = ['productName', 'title', 'subtitle', 'content', 'background']
+
+const LABEL = {
+  productName: '产品名称',
+  title: '标题',
+  subtitle: '副标题',
+  content: '内容',
+  background: '背景图'
+}
+
+/**
+ * 将原文本中 key 对应行替换为「label:value」(保留其它行与换行结构)
+ * @param {string} fullText
+ * @param {keyof typeof LABEL} key
+ * @param {string} value
+ * @returns {string}
+ */
+export function setPromptLineInDescription(fullText, key, value) {
+  const label = LABEL[key]
+  if (!label) return fullText
+  const val = value == null ? '' : String(value)
+  const lines = (fullText || '').split(/\r?\n/)
+  const newLine = `${label}:${val}`
+  for (let i = 0; i < lines.length; i++) {
+    const raw = lines[i]
+    const t = raw.replace(/^\uFEFF/, '').trimStart()
+    if (t.startsWith(label + ':') || t.startsWith(label + ':')) {
+      lines[i] = newLine
+      return lines.join('\n')
+    }
+  }
+  return fullText
+}
+
+/**
+ * 从图层名称推测 prompt key(如「标题 1」「副标题 2」)
+ * @param {string} [name]
+ * @returns {keyof typeof LABEL|null}
+ */
+export function inferPromptKeyFromTextLayerName(name) {
+  if (name == null || typeof name !== 'string') return null
+  const s = name.trim()
+  if (!s) return null
+  const noNum = s.replace(/\s+\d+\s*$/u, '').trim()
+  if (noNum === '标题' || /^标题\s*(\d+)?$/.test(s)) return 'title'
+  if (noNum.startsWith('副标题') || /^副标题/.test(s)) return 'subtitle'
+  if (noNum === '正文' || noNum.startsWith('正文') || /^正文/.test(s)) return 'content'
+  if (noNum.includes('产品名称') || s.includes('产品名称')) return 'productName'
+  if (noNum === '产品名' || noNum.startsWith('产品名')) return 'productName'
+  if (s.includes('背景图') || (noNum.includes('背景') && !noNum.includes('副标题'))) return 'background'
+  return null
+}
+
+/**
+ * 自上向下:同一 key 以画布**最上层**(数组末层、视觉上遮住其它)为主;
+ * 本列表为「底层→顶层」时,自顶向下找第一个命中的 text 层。
+ * @param {Array<{type?:string,name?:string,text?:string,promptSyncKey?:string}>} layersBottomToTop
+ * @param {keyof typeof LABEL} key
+ * @returns {string|undefined} 无匹配则 undefined(不覆写该提示行)
+ */
+export function getTopTextValueForPromptKey(layersBottomToTop, key) {
+  if (!Array.isArray(layersBottomToTop)) return undefined
+  for (const l of [...layersBottomToTop].reverse()) {
+    if (!l || l.type !== 'text') continue
+    const k = l.promptSyncKey || inferPromptKeyFromTextLayerName(l.name || '')
+    if (k === key) {
+      return (l.text != null ? String(l.text) : '').replace(/\r?\n/g, ' ').trim()
+    }
+  }
+  return undefined
+}
+
+/**
+ * 根据当前页所有文字层,生成本页提示词中各已识别栏位的替换结果(完整字符串)
+ * @param {string} baseDescription 当前页 chineseDescription
+ * @param {Array} layersBottomToTop
+ * @param {string} [fallback] 全空时保底(如 DEFAULT_PAGE_CHINESE_DESCRIPTION)
+ * @returns {string}
+ */
+export function mergePromptFromTextLayers(baseDescription, layersBottomToTop, fallback) {
+  let text =
+    baseDescription != null && String(baseDescription).trim() !== '' ? String(baseDescription) : (fallback || '')
+  for (const key of PROMPT_LINE_KEYS) {
+    const v = getTopTextValueForPromptKey(layersBottomToTop, key)
+    if (v !== undefined) {
+      text = setPromptLineInDescription(text, key, v)
+    }
+  }
+  return text
+}

+ 268 - 88
src/view/TemplateManagement/styles/CreateTemplate.scss

@@ -248,7 +248,7 @@
 .materials-panel {
   display: flex;
   flex-direction: column;
-  gap: 8px;
+  gap: 10px;
   height: 100%;
   min-height: 0;
 }
@@ -299,11 +299,11 @@
   flex: 1 1 0;
   min-width: 0;
   position: relative;
-  display: flex;
+  display: block;
 }
 
 .materials-type-chips {
-  flex: 1 1 0;
+  width: 100%;
   min-width: 0;
   display: flex;
   flex-wrap: nowrap;
@@ -312,37 +312,34 @@
   overflow-y: hidden;
   align-items: center;
   padding: 4px 0;
-  min-height: 28px;
+  min-height: 32px;
+  box-sizing: border-box;
   scroll-behavior: smooth;
   -webkit-overflow-scrolling: touch;
+  scrollbar-width: thin;
+  scrollbar-color: #dcdfe6 transparent;
 }
-.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::-webkit-scrollbar { height: 3px; }
+.materials-type-chips::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 2px; }
+.materials-type-chips::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
 
-/* 左右渐变遮罩:提示可滚动 */
-.materials-type-chips-wrap::before,
-.materials-type-chips-wrap::after {
-  content: '';
+/* 内层可滚动区边缘渐变,与标签对齐,避免与外层 ::before 重复 */
+.chips-fade {
   position: absolute;
   top: 0;
   bottom: 0;
-  width: 12px;
+  width: 20px;
+  z-index: 1;
   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;
+.chips-fade-left {
+  left: 0;
+  background: linear-gradient(90deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0) 100%);
 }
-.materials-type-chips-wrap::after {
-  right: 34px;
-  background: linear-gradient(to left, rgba(255,255,255,0.95), transparent);
-  opacity: 0;
+.chips-fade-right {
+  right: 0;
+  background: linear-gradient(270deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0) 100%);
 }
-.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;
@@ -356,29 +353,46 @@
 
 .materials-chip {
   flex-shrink: 0;
-  padding: 4px 10px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 12px;
+  min-height: 28px;
+  box-sizing: border-box;
   border-radius: 14px;
-  border: 1px solid transparent;
-  background: #f3f4f6;
+  border: 1px solid #e4e7ed;
+  background: #f5f7fa;
   color: #606266;
   font-size: 12px;
+  font-weight: 500;
+  line-height: 1.2;
   cursor: pointer;
-  line-height: 1;
-  transition: all 0.15s;
-  max-width: 88px;
+  transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
+  max-width: 100px;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-
 .materials-chip:hover {
   background: #eef2f7;
+  border-color: #dcdfe6;
+  color: #303133;
 }
-
 .materials-chip.active {
   background: #ecf5ff;
-  border-color: #b3d8ff;
-  color: #409eff;
+  border-color: #a0cfff;
+  color: #1677ff;
+  font-weight: 600;
+  box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
+}
+.materials-chip:focus {
+  outline: none;
+}
+.materials-chip:focus-visible {
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.35);
+}
+.materials-chip.active:focus-visible {
+  box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2), 0 0 0 3px rgba(64, 158, 255, 0.25);
 }
 
 .materials-groups {
@@ -391,7 +405,8 @@
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 2px 2px 0;
+  padding: 2px 2px 4px;
+  margin-bottom: 2px;
 }
 
 .materials-group-title {
@@ -1200,38 +1215,114 @@
   color: #409eff;
 }
 
-/* 多页纵向拼接预览弹窗 */
+/* 设计页「预览模版」:与 TemplateDesign.vue 列表弹层同布局(480 宽、viewport 高度、scroller),仅内容区为 DOM 画布 */
 .template-preview-dialog :deep(.el-dialog) {
   max-width: 92vw;
+  width: min(96vw, 480px) !important;
+  display: flex;
+  flex-direction: column;
+  max-height: min(92vh, 900px);
+  margin: 0;
 }
-.template-preview-dialog-body {
-  min-height: 120px;
-  max-height: min(78vh, 900px);
-  overflow: auto;
-  text-align: left;
+.template-preview-dialog :deep(.el-dialog__header) {
+  padding-bottom: 8px;
+  flex-shrink: 0;
+}
+.template-preview-dialog :deep(.el-dialog__body) {
+  padding: 0 12px 16px;
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: hidden;
+}
+@media (max-width: 500px) {
+  .template-preview-dialog :deep(.el-dialog) {
+    width: 96% !important;
+  }
 }
-.template-preview-dom-body {
+.template-preview-dialog .template-strip-preview-inner {
+  width: 100%;
+  min-height: 0;
   display: flex;
   flex-direction: column;
-  gap: 16px;
-  align-items: center;
-  padding: 4px 0 8px;
+  align-items: stretch;
+}
+.template-preview-dialog .template-strip-preview-viewport {
+  position: relative;
+  width: 100%;
+  height: clamp(360px, 72vh, 720px);
+  min-height: 400px;
+  background: #f5f5f5;
+  border-radius: 8px;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+@media (max-height: 700px) {
+  .template-preview-dialog .template-strip-preview-viewport {
+    height: min(60vh, 520px);
+    min-height: 280px;
+  }
+}
+.template-preview-dialog .template-strip-preview-scroller {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  width: 100%;
+  height: 100%;
+  overflow-x: hidden;
+  overflow-y: auto;
+  background: #fff;
+  scrollbar-width: thin;
+  scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);
+}
+.template-preview-dialog .template-strip-preview-scroller:hover {
+  scrollbar-color: rgba(0, 0, 0, 0.22) #fff;
+}
+.template-preview-dialog .template-strip-preview-scroller::-webkit-scrollbar {
+  width: 6px;
+  height: 0;
+}
+.template-preview-dialog .template-strip-preview-scroller::-webkit-scrollbar-track {
+  background: #fff;
+  border-radius: 3px;
+}
+.template-preview-dialog .template-strip-preview-scroller::-webkit-scrollbar-thumb {
+  background: transparent;
+  border-radius: 3px;
+}
+.template-preview-dialog .template-strip-preview-scroller:hover::-webkit-scrollbar-thumb {
+  background: rgba(0, 0, 0, 0.2);
+}
+.template-preview-dialog .template-strip-preview-scroller .template-preview-page-block + .template-preview-page-block {
+  border-top: 1px solid #eee;
+  padding-top: 6px;
+  margin-top: 2px;
 }
 .template-preview-page-block {
   width: 100%;
   max-width: 100%;
+  flex-shrink: 0;
+  box-sizing: border-box;
+  padding: 0 0 6px 0;
+}
+.template-preview-page-block:last-child {
+  padding-bottom: 0;
 }
 .template-preview-page-title {
-  font-size: 12px;
-  color: #606266;
-  margin-bottom: 8px;
+  font-size: 11px;
+  color: #909399;
+  margin: 6px 0 4px;
   text-align: center;
+  line-height: 1.2;
+  flex-shrink: 0;
 }
 .template-preview-page-scale {
   margin: 0 auto;
   overflow: hidden;
   background: #fff;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+  max-width: 100%;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
 }
 .template-preview-page-canvas {
   position: relative;
@@ -1260,8 +1351,8 @@
 .template-preview-float {
   position: absolute;
   z-index: 40;
-  /* 与 .layer-panel 同宽 260px + 间距,避免压在属性栏上 */
-  right: calc(260px + 18px);
+  /* 与 .layer-panel 同宽 + 间距,避免压在属性栏上 */
+  right: calc(300px + 18px);
   bottom: 22px;
   display: inline-flex;
   align-items: center;
@@ -1774,7 +1865,8 @@
   display: flex;
   flex-direction: column;
   background: transparent;
-  overflow: hidden;
+  /* 与 .canvas 一致,避免子层级仍裁切缩放手柄 */
+  overflow: visible;
   padding: 20px;
   align-items: center;
   justify-content: center;
@@ -1839,7 +1931,8 @@
   display: flex;
   justify-content: center;
   align-items: center;
-  overflow: hidden;
+  /* 不可 hidden:否则图层缩放手柄略超出白画布时会被裁掉,靠右/靠上的角「拉不出」 */
+  overflow: visible;
   width: 100%;
   max-width: 100%;
   min-height: 0;
@@ -1861,6 +1954,7 @@
     0 1px 2px rgba(0, 0, 0, 0.06),
     0 8px 28px rgba(0, 0, 0, 0.14),
     0 2px 8px rgba(0, 0, 0, 0.08);
+  /* 裁切到画布内:若设为 visible,图层/选框会画到白底外灰区,像「图超出画布」 */
   overflow: hidden;
 }
 
@@ -1869,18 +1963,21 @@
   cursor: move;
   user-select: none;
   transition: box-shadow 0.2s;
+  z-index: 1;
 }
 
 .layer.selected {
+  z-index: 20;
   box-shadow: 0 0 0 2px #409eff;
 }
 
+/* cover:选框内铺满可见像素,避免 contain 在自由比例下两侧/上下留空像「图浮在框外」 */
 .layer img {
   width: 100%;
   height: 100%;
   display: block;
   pointer-events: none;
-  object-fit: contain;
+  object-fit: cover;
   object-position: center center;
 }
 
@@ -1915,44 +2012,58 @@
   box-shadow: 0 0 0 1px #409eff;
 }
 
+/* 略放大并放在选框内角,+ 不可见热区,避免贴画布边缘时仍被裁切或点不中 */
 .resize-handle {
   position: absolute;
-  width: 10px;
-  height: 10px;
+  width: 12px;
+  height: 12px;
   background-color: #409eff;
   border: 2px solid #fff;
   border-radius: 50%;
+  box-sizing: border-box;
   cursor: pointer;
-  z-index: 10;
+  z-index: 30;
+  touch-action: none;
+}
+.resize-handle::after {
+  content: '';
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  width: 28px;
+  height: 28px;
+  transform: translate(-50%, -50%);
+  border-radius: 4px;
 }
 
 .resize-handle.nw {
-  top: -5px;
-  left: -5px;
+  top: 2px;
+  left: 2px;
   cursor: nw-resize;
 }
 
 .resize-handle.ne {
-  top: -5px;
-  right: -5px;
+  top: 2px;
+  right: 2px;
   cursor: ne-resize;
 }
 
 .resize-handle.sw {
-  bottom: -5px;
-  left: -5px;
+  bottom: 2px;
+  left: 2px;
   cursor: sw-resize;
 }
 
 .resize-handle.se {
-  bottom: -5px;
-  right: -5px;
+  bottom: 2px;
+  right: 2px;
   cursor: se-resize;
 }
 
 .rotate-handle {
   position: absolute;
-  top: -30px;
+  /* 画布 overflow:hidden 时须留在白区内,负 top 会被裁切 */
+  top: 2px;
   left: 50%;
   transform: translateX(-50%);
   width: 16px;
@@ -1961,25 +2072,38 @@
   border: 2px solid #fff;
   border-radius: 50%;
   cursor: grab;
-  z-index: 10;
+  z-index: 30;
+  touch-action: none;
+}
+.rotate-handle::after {
+  content: '';
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  width: 32px;
+  height: 32px;
+  transform: translate(-50%, -50%);
 }
 
 .rotate-handle::before {
+  /* 原竖线在画布内保留短连接即可 */
   content: '';
   position: absolute;
   top: 100%;
   left: 50%;
   transform: translateX(-50%);
   width: 1px;
-  height: 14px;
+  height: 6px;
   background-color: #409eff;
 }
 
-/* 右侧图层面板:固定宽度不收缩,稍窄以让画布更宽 */
+/* 右侧图层面板:固定宽度;min-height:0 保证在 content-area flex 内占满高度并可内部滚动 */
 .layer-panel {
-  flex: 0 0 260px;
-  width: 260px;
-  min-width: 260px;
+  flex: 0 0 300px;
+  width: 300px;
+  min-width: 300px;
+  min-height: 0;
+  align-self: stretch;
   background: #f5f6f8;
   border-left: 1px solid #b8bec8;
   padding: 12px;
@@ -2030,6 +2154,12 @@
   flex: 1 1 0;
   overflow: hidden;
 }
+/* 图层管理:整列可纵向滚动,避免提示词被压扁后需反复「拉开」窗口才可见 */
+.right-section:not(.right-section-props) {
+  overflow-y: auto;
+  overflow-x: hidden;
+  -webkit-overflow-scrolling: touch;
+}
 .right-section-props {
   flex: 1 1 0;
   min-height: 0;
@@ -2060,23 +2190,50 @@
   padding: 8px 0;
 }
 
+.page-prompt-block {
+  flex-shrink: 0;
+  margin-bottom: 10px;
+  padding: 10px 10px 8px;
+  background: #f8fafc;
+  border-radius: 8px;
+  border: 1px solid #ebeef5;
+}
+.page-prompt-label {
+  font-size: 12px;
+  font-weight: 600;
+  color: #606266;
+  margin-bottom: 6px;
+}
+.page-prompt-block :deep(.el-textarea) {
+  width: 100%;
+}
+.page-prompt-block :deep(.el-textarea__inner) {
+  font-size: 12px;
+  line-height: 1.45;
+  resize: none;
+  min-height: 168px;
+  box-sizing: border-box;
+}
+
 .layer-actions {
   display: flex;
   gap: 6px;
   flex-shrink: 0;
 }
 
+/* 与「提示词」同列一并滚动,避免嵌套双滚动条、反复找滚动区域 */
 .layer-list {
-  flex: 1;
+  flex: 0 0 auto;
   margin-top: 4px;
   min-height: 0;
-  overflow-y: auto;
+  overflow: visible;
 }
 
-/* 卡片式图层项:拖拽手柄 | 缩略图 | 名称 | 可见性 | 更多 */
+/* 卡片式图层项:上排 拖拽|缩略图|操作;下排 名称多 */
 .layer-item-card {
   display: flex;
-  align-items: center;
+  flex-direction: column;
+  align-items: stretch;
   padding: 8px 10px;
   margin-bottom: 6px;
   background: #fff;
@@ -2084,11 +2241,34 @@
   border: 1px solid #ebeef5;
   cursor: pointer;
   transition: all 0.2s;
-  gap: 8px;
+  gap: 6px;
   user-select: none;
   box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
 }
 
+.layer-item-card-upper {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+
+.layer-item-card-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  margin-left: auto;
+  flex-shrink: 0;
+}
+
+.layer-item-card-lower {
+  min-width: 0;
+}
+.layer-item-card-lower .layer-card-name-wrap {
+  width: 100%;
+}
+
 .layer-item-card:hover {
   border-color: #c0c4cc;
   background: #fafafa;
@@ -2158,40 +2338,40 @@
 .layer-card-name {
   flex: 1;
   min-width: 0;
-  overflow: hidden;
-  font-size: 13px;
+  font-size: 12px;
   color: #303133;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  line-height: 1.4;
+  white-space: normal;
+  word-break: break-word;
   cursor: pointer;
+  display: block;
 }
 
 .layer-card-name-wrap {
-  flex: 1;
   min-width: 0;
   display: flex;
-  align-items: center;
-  flex-wrap: wrap;
+  flex-direction: column;
+  align-items: flex-start;
   gap: 4px;
+  width: 100%;
 }
 .layer-card-name-trigger {
-  flex: 1;
   min-width: 0;
+  width: 100%;
   display: flex;
-  align-items: center;
-  overflow: hidden;
+  align-items: flex-start;
+  gap: 4px;
 }
 .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;
+  width: 100%;
 }
 .layer-card-name-input {
   flex: 1;

+ 69 - 26
src/view/TemplateManagement/utils.js

@@ -49,10 +49,28 @@ export function getImageLoadUrl(url) {
 
 
 /**
- * 画布导出用 URL 列表:
- * - 优先同源代理(VITE_PREVIEW_IMAGE_PROXY),由后端拉 OSS 再返回,浏览器 fetch 同源不依赖 OSS CORS。
- * - 其次直连 OSS(需 Bucket 配置 CORS 或 fetch 会失败)。
- * - HTTPS 页面将 http 图链升为 https,避免混合内容。
+ * 将 OSS 全链的 /uploads/... 改写成「当前站同源 + /uploads/...」。
+ * 仅当本机/代理上确有与 OSS 同路径文件时使用;否则 Vite 会 404,且比直连 OSS 先被 fetch 尝试,导致多页/导出无图。
+ * 默认不启用,需显式 VITE_EXPORT_SAME_ORIGIN_UPLOADS=1(且本地有同名文件与 OSS 镜像)。
+ */
+function getSameOriginUploadsIfApplicable(httpUrl) {
+  if (typeof window === 'undefined' || !httpUrl || !/^https?:\/\//i.test(httpUrl)) return null
+  const on = typeof import.meta !== 'undefined' && import.meta.env?.VITE_EXPORT_SAME_ORIGIN_UPLOADS
+  if (on !== '1' && on !== 'true') return null
+  try {
+    const parsed = new URL(httpUrl, window.location.href)
+    const p = (parsed.pathname || '').replace(/\/+/g, '/')
+    if (p.indexOf('/uploads/') === -1) return null
+    if (parsed.origin === window.location.origin) return null
+    return `${window.location.origin}${p}${parsed.search || ''}`
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 画布导出用 URL 尝试顺序:素材在 OSS 上时**先直连 out**,避免先打代理/同源 404 再重试,Network 里像「要很多次才成功」。
+ * 再:VITE_PREVIEW_IMAGE_PROXY、可选同源 uploads(VITE_EXPORT_SAME_ORIGIN_UPLOADS)。
  */
 function resolveCanvasExportUrlCandidates(rawUrl) {
   const u = getImageLoadUrl(rawUrl.trim())
@@ -61,8 +79,9 @@ function resolveCanvasExportUrlCandidates(rawUrl) {
   let out = u
   if (out.startsWith('//')) out = 'https:' + out
 
+  const locBase = typeof window !== 'undefined' ? window.location.href : 'http://localhost/'
   try {
-    const parsed = new URL(out)
+    const parsed = new URL(out, locBase)
     if (
       typeof window !== 'undefined' &&
       window.isSecureContext &&
@@ -70,8 +89,8 @@ function resolveCanvasExportUrlCandidates(rawUrl) {
       parsed.protocol === 'http:'
     ) {
       parsed.protocol = 'https:'
-      out = parsed.toString()
     }
+    out = parsed.href
   } catch {
     /* ignore */
   }
@@ -81,28 +100,46 @@ function resolveCanvasExportUrlCandidates(rawUrl) {
     if (x && !list.includes(x)) list.push(x)
   }
 
+  let preferDirectOssFirst = false
+  try {
+    const h = new URL(out, locBase).hostname || ''
+    preferDirectOssFirst =
+      h.includes('aliyuncs.com') || /\.oss-/i.test(h) || h.includes('myqcloud.com')
+  } catch {
+    /* keep false */
+  }
+  if (preferDirectOssFirst && /^https?:\/\//i.test(out)) {
+    push(out)
+  }
+
   const proxy = typeof import.meta !== 'undefined' && import.meta.env?.VITE_PREVIEW_IMAGE_PROXY
   if (proxy && String(proxy).trim() && /^https?:\/\//i.test(out)) {
     const base = String(proxy).trim().replace(/\/$/, '')
     push(`${base}?url=${encodeURIComponent(out)}`)
   }
-  push(out)
+  const sameOriginUploads = getSameOriginUploadsIfApplicable(out)
+  if (sameOriginUploads) push(sameOriginUploads)
+  if (!preferDirectOssFirst || !list.includes(out)) {
+    push(out)
+  }
 
   return list
 }
 
 /**
- * 导出用 URL 加一次性查询参数,避免命中「仅展示用」的 img 缓存(无 CORS 头),否则后续 fetch/crossOrigin 会异常或 304 仍不可用。
+ * 导出用:在**查询串**中追加 _exportcb=时间戳_随机串,用于绕过无 CORS 的 img 缓存。
+ * 必须用 URL/searchParams 生成,使 ? 与 path 正确分隔;不得拼成 .png_exportcb=(否则 404,且像「文件名里含 =」)。
  */
 function addExportCacheBust(url) {
   if (!url || url.startsWith('data:')) return url
+  const base = typeof window !== 'undefined' ? window.location.href : 'http://localhost/'
+  const s = String(url).trim()
   try {
-    const u = new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
+    const u = new URL(s, base)
     u.searchParams.set('_exportcb', `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`)
-    return u.toString()
+    return u.href
   } catch {
-    const sep = url.includes('?') ? '&' : '?'
-    return `${url}${sep}_exportcb=${Date.now()}`
+    return s
   }
 }
 
@@ -162,11 +199,11 @@ export async function loadImageForCanvasExport(rawUrl) {
     referrerPolicy: 'no-referrer'
   }
 
+  /** fetch 已 cache:'no-store',不必每次换 _exportcb;多候选顺序见 resolveCanvasExportUrlCandidates */
   for (const url of candidates) {
     if (!url || url.startsWith('data:')) continue
-    const busted = addExportCacheBust(url)
     try {
-      const res = await fetch(busted, fetchOpts)
+      const res = await fetch(url, fetchOpts)
       if (res.ok) {
         const drawable = await blobToDrawable(await res.blob())
         if (drawable) return drawable
@@ -181,24 +218,30 @@ export async function loadImageForCanvasExport(rawUrl) {
     const img = new Image()
     img.crossOrigin = 'anonymous'
     const busted = addExportCacheBust(url)
-    await new Promise((resolve) => {
-      img.onload = () => resolve()
-      img.onerror = () => resolve()
-      img.src = busted
-    })
-    if (img.complete && img.naturalWidth) return img
+    const toTry = busted !== url ? [url, busted] : [url]
+    for (const src of toTry) {
+      await new Promise((resolve) => {
+        img.onload = () => resolve()
+        img.onerror = () => resolve()
+        img.src = src
+      })
+      if (img.complete && img.naturalWidth) return img
+    }
   }
 
   for (const url of candidates) {
     if (!url || url.startsWith('data:')) continue
     const img2 = new Image()
     const busted2 = addExportCacheBust(url)
-    await new Promise((resolve) => {
-      img2.onload = () => resolve()
-      img2.onerror = () => resolve()
-      img2.src = busted2
-    })
-    if (img2.complete && img2.naturalWidth) return img2
+    const toTry2 = busted2 !== url ? [url, busted2] : [url]
+    for (const src of toTry2) {
+      await new Promise((resolve) => {
+        img2.onload = () => resolve()
+        img2.onerror = () => resolve()
+        img2.src = src
+      })
+      if (img2.complete && img2.naturalWidth) return img2
+    }
   }
 
   return null

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů