浏览代码

feat(marketingEdit): 更新画布模板预览图片与商品模式配置

- 更新多个画布模板的 preview 图片链接
- 为画布模板添加 max_goods_count 字段以支持多商品模式
- 配置不同画布的商品模式 multi_goods_mode 和最大商品数量
- 调整部分画布中商品展示相关参数配置
panqiuyao 1 周之前
父节点
当前提交
84662aff1e

+ 84 - 0
electron/controller/utils.js

@@ -281,6 +281,90 @@ class UtilsController extends Controller {
           base64Image:dataUrl
         };
   }
+
+  /**
+   * 将前端生成的详情图数据写入 EXE 同级目录下的 output 文件夹。
+   * @param {{ bundles: Array }} payload
+   */
+  async saveGeneratedImages (payload = {}) {
+    try {
+      const { app } = require('electron');
+      const bundles = payload.bundles || [];
+      if (!Array.isArray(bundles) || !bundles.length) {
+        return { code: 1, msg: '无可保存的图片数据' };
+      }
+
+      // 运行目录:打包后为 EXE 所在目录,开发环境为项目根目录
+      const isPackaged = app.isPackaged;
+      const exeDir = isPackaged ? path.dirname(app.getPath('exe')) : process.cwd();
+      const baseOutputDir = path.join(exeDir, 'output');
+
+      if (!fs.existsSync(baseOutputDir)) {
+        fs.mkdirSync(baseOutputDir, { recursive: true });
+      }
+
+      let fileCount = 0;
+
+      const saveDataUrlToFile = (dataUrl, targetPath) => {
+        if (!dataUrl || typeof dataUrl !== 'string') return;
+        const match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
+        const base64Data = match ? match[1] : dataUrl;
+        const buffer = Buffer.from(base64Data, 'base64');
+        const dir = path.dirname(targetPath);
+        if (!fs.existsSync(dir)) {
+          fs.mkdirSync(dir, { recursive: true });
+        }
+        fs.writeFileSync(targetPath, buffer);
+        fileCount += 1;
+      };
+
+      bundles.forEach(bundle => {
+        if (!bundle) return;
+        const styleNo = (bundle.styleNo || bundle.styleKey || 'UNKNOWN').toString().replace(/[\\\/:*?"<>|]/g, '_');
+        const styleDir = path.join(baseOutputDir, styleNo);
+        if (!fs.existsSync(styleDir)) {
+          fs.mkdirSync(styleDir, { recursive: true });
+        }
+
+        // 单画布图片
+        (bundle.images || []).forEach(img => {
+          if (!img || !img.dataUrl) return;
+          const firstSku = (img.skus && img.skus[0]) || {};
+          const skuCode = (firstSku.sku || '').toString().replace(/[\\\/:*?"<>|]/g, '_');
+          const fileNameParts = [
+            `canvas_${img.canvasIndex}`,
+            `img_${img.imageIndex}`,
+          ];
+          if (skuCode) {
+            fileNameParts.push(skuCode);
+          }
+          const fileName = fileNameParts.join('_') + '.png';
+          const targetPath = path.join(styleDir, fileName);
+          saveDataUrlToFile(img.dataUrl, targetPath);
+        });
+
+        // 合成长图
+        if (bundle.combined && bundle.combined.dataUrl) {
+          const combinedPath = path.join(styleDir, 'all_canvases.png');
+          saveDataUrlToFile(bundle.combined.dataUrl, combinedPath);
+        }
+      });
+
+      return {
+        code: 0,
+        data: {
+          outputDir: baseOutputDir,
+          fileCount,
+        },
+      };
+    } catch (error) {
+      console.error('saveGeneratedImages error:', error);
+      return {
+        code: 1,
+        msg: error.message || '保存生成图片失败',
+      };
+    }
+  }
   async openFile(optiops= {
     title:"选择文件",
     filters:[

+ 1 - 0
frontend/src/utils/ipc.ts

@@ -22,6 +22,7 @@ const icpList = {
         getAppConfig:"controller.utils.getAppConfig",
         openFile:"controller.utils.openFile",
         runExternalTool:"controller.utils.runExternalTool",
+        saveGeneratedImages:"controller.utils.saveGeneratedImages",
         closeAllWindows: 'controller.utils.closeAllWindows'
     },
     setting:{

+ 1 - 1
frontend/src/views/Tpl/Edit/index.vue

@@ -36,7 +36,7 @@
             value: 'C:\\Users\\Administrator\\Desktop\\img\\A596351\\2.png'
           },
           {
-            key: '后',
+            key: '后',
             value: 'C:\\Users\\Administrator\\Desktop\\img\\A596351\\3.png'
           },
           {

文件差异内容过多而无法显示
+ 1 - 1
frontend/src/views/components/marketingEdit/canvas.json


+ 171 - 0
frontend/src/views/components/marketingEdit/generateImagesPlan.js

@@ -0,0 +1,171 @@
+import canvasTemplate from './canvas.json'
+import goodsTemplate from './goods.json'
+import goodsTemplate1 from './goods1.json'
+
+/**
+ * 规范化商品数据:拍平为一个货号数组
+ * 每个元素结构:
+ * {
+ *   sku: 'A596351',
+ *   color: '黑色',
+ *   pics: { '俯视': '...', '侧视': '...' },
+ *   design: '设计理念文案',
+ *   styleKey: 'AC5120913',
+ *   styleNo: 'E305-01003',
+ *   raw: 原始对象
+ * }
+ */
+export function normalizeGoods(goodsList = []) {
+  const skus = []
+  goodsList.forEach(group => {
+    if (!group) return
+    Object.entries(group).forEach(([styleKey, entry]) => {
+      if (!entry || !Array.isArray(entry['货号资料'])) return
+      const styleNo = entry['款号'] || styleKey
+      entry['货号资料'].forEach(sku => {
+        const design = sku['设计理念'] || entry['设计理念'] || ''
+        skus.push({
+          sku: sku['货号'],
+          color: sku['颜色'],
+          pics: sku['pics'] || {},
+          design,
+          styleKey,
+          styleNo,
+          // raw 中同时带上整条 entry(可能包含 卖点 / 使用场景 等其他字段)
+          raw: {
+            ...entry,
+            ...sku,
+          },
+        })
+      })
+    })
+  })
+  return skus
+}
+
+/**
+ * 根据画布配置和商品数据,计算每个画布需要生成多少张图片、每张图片使用哪些货号。
+ *
+ * 这里只做「任务规划」,不真正去用 fabric 生成图片,方便在任意地方静默调用。
+ *
+ * @param {Array} canvasList - 画布配置数组(同 marketingEdit 保存出去的结构)
+ * @param {Array} goodsList  - 商品数据数组,例如 [goods, goods1]
+ * @returns {Array} renderPlans
+ *
+ * renderPlans 结构示例:
+ * [
+ *   {
+ *     canvasIndex: 2,
+ *     multi_goods_mode: 'multiple',
+ *     max_goods_count: 2,
+ *     perCanvasSlots: 2,              // 此画布里,单张图最多可展示的货号数(same-angle 图层个数)
+ *     images: [
+ *       { imageIndex: 0, skuIndexes: [0,1] },
+ *       { imageIndex: 1, skuIndexes: [2,null] } // null 表示该位置需要隐藏图层
+ *     ]
+ *   },
+ *   ...
+ * ]
+ */
+export function buildRenderPlans(canvasList = canvasTemplate, goodsList = [goodsTemplate, goodsTemplate1]) {
+  const skus = normalizeGoods(goodsList)
+  const totalSkuCount = skus.length
+  if (!totalSkuCount) return []
+
+  const plans = []
+
+  canvasList.forEach((canvasItem, cIdx) => {
+    if (!canvasItem || !canvasItem.canvas_json) return
+
+    const mode = canvasItem.multi_goods_mode || '' // '' | 'single' | 'multiple'
+    const maxGoods = Number(canvasItem.max_goods_count) || null
+
+    // 解析 canvas_json,统计「货号相关图层」数量,用于 multiple 模式下计算每张图可放几个货号
+    let perCanvasSlots = 1
+    try {
+      const json = typeof canvasItem.canvas_json === 'string'
+        ? JSON.parse(canvasItem.canvas_json)
+        : canvasItem.canvas_json
+      const objs = (json && Array.isArray(json.objects)) ? json.objects : []
+      const imgPlaceholders = objs.filter(o => o && o['data-type'] === 'img')
+      if (mode === 'multiple') {
+        perCanvasSlots = Math.max(imgPlaceholders.length, 1)
+      } else {
+        perCanvasSlots = 1
+      }
+    } catch (e) {
+      perCanvasSlots = 1
+    }
+
+    const images = []
+
+    if (!mode) {
+      /**
+       * 默认(单货号):
+       * - 无论有多少货号,只生成 1 张图片
+       * - 画布内所有动态文字 / 图片都使用「第一个货号」的数据
+       */
+      images.push({
+        imageIndex: 0,
+        skuIndexes: [0], // 始终只用第一个货号
+      })
+    } else if (mode === 'single') {
+      /**
+       * 一个货号多角度:
+       * - 一张图只展示一个货号
+       * - 生成张数 = min(总货号数, 最多货号数量)
+       *   例:总 3 个货号,最多货号数为 2 -> 生成 2 张图,分别用前两个货号
+       */
+      const limit = maxGoods
+        ? Math.min(totalSkuCount, maxGoods)
+        : totalSkuCount
+
+      for (let i = 0; i < limit; i++) {
+        images.push({
+          imageIndex: i,
+          skuIndexes: [i], // 每张图只使用一个货号
+        })
+      }
+    } else if (mode === 'multiple') {
+      /**
+       * 多个货号同角度:
+       * - 一张图里可以有 perCanvasSlots 个货号位(根据画布中 img 占位层数量推算)
+       * - 需要的张数 = ceil(总货号数 / 每张可展示数)
+       * - 最终张数 = min(需要的张数, 最多货号数量(如果有的话))
+       *   例:画布中有 2 个货号位,商品有 3 个货号,最多货号数=4
+       *   -> 需要张数 ceil(3/2)=2,受限于 4 -> 实际生成 2 张:
+       *      第 1 张:[sku0, sku1],第 2 张:[sku2, null]
+       */
+      let maxImagesByGoods = Math.ceil(totalSkuCount / perCanvasSlots)
+      if (maxGoods) {
+        maxImagesByGoods = Math.min(maxImagesByGoods, maxGoods)
+      }
+
+      for (let i = 0; i < maxImagesByGoods; i++) {
+        const skuIndexes = []
+        for (let s = 0; s < perCanvasSlots; s++) {
+          const skuIdx = i * perCanvasSlots + s
+          skuIndexes.push(skuIdx < totalSkuCount ? skuIdx : null)
+        }
+        images.push({
+          imageIndex: i,
+          skuIndexes,
+        })
+      }
+    }
+
+    if (!images.length) return
+
+    plans.push({
+      canvasIndex: cIdx,
+      multi_goods_mode: mode,
+      max_goods_count: maxGoods,
+      perCanvasSlots,
+      images,
+    })
+  })
+
+  return plans
+}
+
+

+ 328 - 0
frontend/src/views/components/marketingEdit/generateImagesRender.js

@@ -0,0 +1,328 @@
+import fabric from '../PictureEditor/js/fabric-adapter'
+import { buildRenderPlans, normalizeGoods } from './generateImagesPlan'
+
+/**
+ * 根据渲染计划和拍平后的货号数据,真正生成图片(dataURL),按 canvasIndex & imageIndex 返回。
+ *
+ * 注意:
+ * - 不做任何上传,只返回 dataURL,方便在 EXE 或其他环境里落地到本地文件。
+ * - 需要在外部保证 canvasList / plans / skus 的来源一致。
+ *
+ * @param {Array} plans 由 buildRenderPlans 生成的渲染计划
+ * @param {Array} canvasList 画布配置数组(包含 canvas_json / width / height / bg_color 等)
+ * @param {Array} skus normalizeGoods(goodsList) 的结果
+ * @returns {Promise<Array<{canvasIndex:number,imageIndex:number,dataUrl:string,skuIndexes:number[],skus:any[]}>>}
+ */
+export async function renderImagesByPlans(plans, canvasList, skus) {
+  const results = []
+
+  // 工具:从 canvas_json 创建一个离屏 fabric.Canvas,并应用一次性的渲染逻辑
+  const renderOne = (canvasItem, planForCanvas, imagePlan) =>
+    new Promise((resolve) => {
+      if (!canvasItem || !canvasItem.canvas_json) {
+        return resolve(null)
+      }
+
+      const { canvasIndex } = planForCanvas
+      const { imageIndex, skuIndexes } = imagePlan
+
+      // 解析 JSON 为可修改的对象
+      let json
+      try {
+        json = typeof canvasItem.canvas_json === 'string'
+          ? JSON.parse(canvasItem.canvas_json)
+          : JSON.parse(JSON.stringify(canvasItem.canvas_json))
+      } catch (e) {
+        console.warn('[generateImagesRender] parse canvas_json failed', e)
+        return resolve(null)
+      }
+
+      const width = Number(canvasItem.width) || 395
+      const height = Number(canvasItem.height) || 600
+      const bgColor = canvasItem.bg_color || '#fff'
+
+      try {
+        const mode = canvasItem.multi_goods_mode || ''
+        const perCanvasSlots = planForCanvas.perCanvasSlots || 1
+        const usedSkus = (skuIndexes || []).map((idx) => (idx != null ? skus[idx] : null))
+
+        // 先在原始 JSON 上做数据替换,避免 setSrc 的异步问题;
+        // loadFromJSON 会在所有图片加载完成后才触发回调。
+        const objs = (json && Array.isArray(json.objects)) ? json.objects : []
+
+        // 1) 处理图片占位(data-type = img)
+        const imgPlaceholders = objs.filter((o) => o && o['data-type'] === 'img')
+
+        if (mode === 'multiple') {
+          // 多个货号同角度:同一画布中,有多个 img 占位,按顺序对应 skuIndexes
+          imgPlaceholders.forEach((obj, idx) => {
+            const slotIndex = idx % perCanvasSlots
+            const sku = usedSkus[slotIndex]
+            if (!sku) {
+              obj.visible = false
+              return
+            }
+            const angleKey = obj['data-key']
+            const url = (sku.pics && sku.pics[angleKey]) || ''
+            if (!url) {
+              obj.visible = false
+              return
+            }
+            obj.visible = true
+            obj['data-value'] = url
+            obj.src = url
+          })
+        } else {
+          // 默认 / single:一个货号多角度,一张图只用一个货号
+          const sku = usedSkus[0]
+          imgPlaceholders.forEach((obj) => {
+            if (!sku) {
+              obj.visible = false
+              return
+            }
+            const angleKey = obj['data-key']
+            const url = (sku.pics && sku.pics[angleKey]) || ''
+            if (!url) {
+              obj.visible = false
+              return
+            }
+            obj.visible = true
+            obj['data-value'] = url
+            obj.src = url
+          })
+        }
+
+        // 2) 处理文字占位(data-type = text)
+        const textPlaceholders = objs.filter((o) => o && o['data-type'] === 'text')
+
+        if (textPlaceholders.length) {
+          // 通用的 key -> 文本 映射函数,
+          const mapKeyToText = (sku, key, defaultVal) => {
+            if (!sku) return defaultVal
+            let textVal = defaultVal || ''
+            if (key === '颜色') {
+              textVal = sku.color || textVal
+            } else if (key === '货号') {
+              textVal = sku.sku || textVal
+            }
+            // 兜底:去 raw 里找同名字段(支持 卖点 / 使用场景 / 其他自定义字段)
+            if ((!textVal || textVal === defaultVal) && sku.raw && sku.raw[key] != null) {
+              textVal = sku.raw[key]
+            }
+            // 再兜底:如果 sku 上有同名字段
+            if ((!textVal || textVal === defaultVal) && sku[key] != null) {
+              textVal = sku[key]
+            }
+            return textVal
+          }
+
+          if (mode === 'multiple') {
+            // 多个货号同角度:
+            // - 按 data-key + 出现顺序把文字分配给不同货号
+            // - 如果该 slot 没有对应货号(usedSkus[slotIndex] 为 null),则隐藏该文字层
+            const keyCounter = {}
+            textPlaceholders.forEach((obj) => {
+              const key = obj['data-key']
+              if (!key) return
+              const idxForKey = keyCounter[key] || 0
+              keyCounter[key] = idxForKey + 1
+
+              const slotIndex = idxForKey % perCanvasSlots
+              const sku = usedSkus[slotIndex]
+              if (!sku) {
+                obj.visible = false
+                return
+              }
+
+              const origin = obj['data-value'] || ''
+              const textVal = mapKeyToText(sku, key, origin)
+
+              obj.visible = true
+              obj.text = textVal
+              obj['data-value'] = textVal
+            })
+          } else {
+            // 默认 / single:全部文字都使用同一个货号(默认模式只生成 1 张,用第一个货号)
+            const sku = usedSkus[0]
+            if (sku) {
+              textPlaceholders.forEach((obj) => {
+                const key = obj['data-key']
+                if (!key) return
+                const origin = obj['data-value'] || ''
+                const textVal = mapKeyToText(sku, key, origin)
+
+                obj.visible = true
+                obj.text = textVal
+                obj['data-value'] = textVal
+              })
+            }
+          }
+        }
+
+        // 创建离屏 canvas
+        const el = document.createElement('canvas')
+        el.width = width
+        el.height = height
+
+        const fcanvas = new fabric.Canvas(el, {
+          backgroundColor: bgColor,
+          width,
+          height,
+          renderOnAddRemove: false,
+        })
+
+        // 把已经替换好动态数据的 JSON 加载进 fabric;
+        // loadFromJSON 会等所有图片资源加载完之后才调用回调函数
+        fcanvas.loadFromJSON(json, () => {
+          try {
+            fcanvas.renderAll()
+            const dataUrl = fcanvas.toDataURL({
+              format: 'png',
+              enableRetinaScaling: true,
+            })
+
+            fcanvas.dispose()
+
+            resolve({
+              canvasIndex,
+              imageIndex,
+              dataUrl,
+              skuIndexes,
+              skus: usedSkus,
+            })
+          } catch (e) {
+            console.warn('[generateImagesRender] render one failed in callback', e)
+            try {
+              fcanvas.dispose()
+            } catch (e2) {}
+            resolve(null)
+          }
+        })
+      } catch (e) {
+        console.warn('[generateImagesRender] render one failed', e)
+        resolve(null)
+      }
+    })
+
+  for (const plan of plans || []) {
+    const canvasItem = canvasList[plan.canvasIndex]
+    if (!canvasItem) continue
+
+    for (const imgPlan of plan.images || []) {
+      // eslint-disable-next-line no-await-in-loop
+      const res = await renderOne(canvasItem, plan, imgPlan)
+      if (res) results.push(res)
+    }
+  }
+
+  return results
+}
+
+/**
+ * 针对每个款号(style),按画布生成所有图片,并额外生成「所有画布组合在一起」的一张长图。
+ *
+ * @param {Array} canvasList 画布配置数组
+ * @param {Array} goodsList  原始商品数据数组 [goods, goods1, ...]
+ * @returns {Promise<Array<{
+ *   styleKey: string,
+ *   styleNo: string,
+ *   images: Array<{canvasIndex:number,imageIndex:number,dataUrl:string,skuIndexes:number[],skus:any[]}>,
+ *   combined?: { dataUrl: string, width: number, height: number }
+ * }>>}
+ */
+export async function generateAllStyleImageBundles(canvasList, goodsList) {
+  const bundles = []
+
+  // 工具:把若干 dataURL 竖向拼接为一张长图
+  const composeCombinedImage = (images) =>
+    new Promise((resolve) => {
+      if (!images || !images.length) return resolve(null)
+
+      Promise.all(
+        images.map(img =>
+          new Promise(res => {
+            fabric.Image.fromURL(
+              img.dataUrl,
+              (oImg) => res(oImg),
+              { crossOrigin: 'anonymous' }
+            )
+          })
+        )
+      ).then(fabricImages => {
+        const widths = fabricImages.map(i => i.width * (i.scaleX || 1))
+        const heights = fabricImages.map(i => i.height * (i.scaleY || 1))
+        const totalHeight = heights.reduce((a, b) => a + b, 0)
+        const maxWidth = widths.reduce((a, b) => Math.max(a, b), 0)
+
+        const el = document.createElement('canvas')
+        el.width = maxWidth
+        el.height = totalHeight
+
+        const canvas = new fabric.Canvas(el, {
+          backgroundColor: '#fff',
+          width: maxWidth,
+          height: totalHeight,
+          renderOnAddRemove: false,
+        })
+
+        let currentTop = 0
+        fabricImages.forEach((img, idx) => {
+          const w = widths[idx]
+          const h = heights[idx]
+          img.set({
+            left: (maxWidth - w) / 2,
+            top: currentTop,
+          })
+          currentTop += h
+          canvas.add(img)
+        })
+
+        canvas.renderAll()
+        const dataUrl = canvas.toDataURL({
+          format: 'png',
+          enableRetinaScaling: true,
+        })
+        const result = { dataUrl, width: maxWidth, height: totalHeight }
+        canvas.dispose()
+        resolve(result)
+      }).catch(() => resolve(null))
+    })
+
+  // 按款号分组处理,避免不同款号的货号混在一起
+  for (const group of goodsList || []) {
+    if (!group) continue
+    for (const [styleKey, entry] of Object.entries(group)) {
+      if (!entry || !Array.isArray(entry['货号资料'])) continue
+      const styleNo = entry['款号'] || styleKey
+      const styleGoodsList = [{ [styleKey]: entry }]
+
+      const plans = buildRenderPlans(canvasList, styleGoodsList)
+      const skus = normalizeGoods(styleGoodsList)
+      if (!plans.length || !skus.length) continue
+
+      // eslint-disable-next-line no-await-in-loop
+      const images = await renderImagesByPlans(plans, canvasList, skus)
+      if (!images.length) continue
+
+      // 按 canvasIndex、imageIndex 排序,方便后续命名和组合
+      images.sort((a, b) => {
+        if (a.canvasIndex !== b.canvasIndex) return a.canvasIndex - b.canvasIndex
+        return a.imageIndex - b.imageIndex
+      })
+
+      // 组合所有画布图片为一张长图(可以根据需要选择只取每个画布的第一张等)
+      const combined = await composeCombinedImage(images)
+
+      bundles.push({
+        styleKey,
+        styleNo,
+        images,
+        combined,
+      })
+    }
+  }
+
+  return bundles
+}
+
+

+ 18 - 5
frontend/src/views/components/marketingEdit/goods.json

@@ -11,20 +11,33 @@
           "后跟": "C:\\Users\\Administrator\\Desktop\\img\\A596351\\3.png",
           "鞋底": "C:\\Users\\Administrator\\Desktop\\img\\A596351\\4.png",
           "内里": "C:\\Users\\Administrator\\Desktop\\img\\A5963521\\5.png"
-        }
+        },
+        "设计理念": "平衡舒适性、美观性、功能性和工艺品质,以满足现代消费者在不同场景下的需求"
       },
       {
         "货号": "A596352",
-        "颜色": "色",
+        "颜色": "色",
         "pics": {
           "俯视": "C:\\Users\\Administrator\\Desktop\\img\\A596352\\1.png",
           "侧视": "C:\\Users\\Administrator\\Desktop\\img\\A596352\\2.png",
           "后跟": "C:\\Users\\Administrator\\Desktop\\img\\A596352\\3.png",
           "鞋底": "C:\\Users\\Administrator\\Desktop\\img\\A596352\\4.png",
           "内里": "C:\\Users\\Administrator\\Desktop\\img\\A596352\\5.png"
-        }
+        },
+        "设计理念": "平衡舒适性、美观性、功能性和工艺品质,以满足现代消费者在不同场景下的需求"
+      },
+      {
+        "货号": "A596353",
+        "颜色": "黑色1",
+        "pics": {
+          "俯视": "C:\\Users\\Administrator\\Desktop\\img\\A596351\\1.png",
+          "侧视": "C:\\Users\\Administrator\\Desktop\\img\\A596351\\2.png",
+          "后跟": "C:\\Users\\Administrator\\Desktop\\img\\A596351\\3.png",
+          "鞋底": "C:\\Users\\Administrator\\Desktop\\img\\A596351\\4.png",
+          "内里": "C:\\Users\\Administrator\\Desktop\\img\\A5963521\\5.png"
+        },
+        "设计理念": "平衡舒适性、美观性、功能性和工艺品质,以满足现代消费者在不同场景下的需求"
       }
-    ],
-    "设计理念": "平衡舒适性、美观性、功能性和工艺品质,以满足现代消费者在不同场景下的需求"
+    ]
   }
 }

+ 19 - 0
frontend/src/views/components/marketingEdit/goods1.json

@@ -0,0 +1,19 @@
+{
+  "A59638": {
+    "款号": "A596381",
+    "货号资料": [
+      {
+        "货号": "A596381",
+        "颜色": "红色",
+        "pics": {
+          "俯视": "C:\\Users\\Administrator\\Desktop\\img\\A596381\\1.png",
+          "侧视": "C:\\Users\\Administrator\\Desktop\\img\\A596381\\2.png",
+          "后跟": "C:\\Users\\Administrator\\Desktop\\img\\A596381\\3.png",
+          "鞋底": "C:\\Users\\Administrator\\Desktop\\img\\A596381\\4.png",
+          "内里": "C:\\Users\\Administrator\\Desktop\\img\\A596381\\5.png"
+        },
+        "设计理念": "通过轻质材料(如EVA泡沫)和柔性底纹设计,减轻鞋身重量,同时保持鞋底的弯折性,适合通勤或休闲活动"
+      }
+    ]
+  }
+}

+ 44 - 4
frontend/src/views/components/marketingEdit/index.vue

@@ -10,8 +10,14 @@ import colorMixins from '@/views/components/PictureEditor/mixin/color/index'
 import editMixins from '@/views/components/PictureEditor/mixin/edit/index'
 import { uploadBaseImg } from '@/apis/other'
 import {markRaw} from "vue";
+import { ElMessage } from 'element-plus'
+import useClientStore from '@/stores/modules/client'
+import icpList from '@/utils/ipc'
 import goods from './goods.json'
+import goods1 from './goods1.json'
 import canvas from './canvas.json'
+import { buildRenderPlans, normalizeGoods } from './generateImagesPlan'
+import { renderImagesByPlans, generateAllStyleImageBundles } from './generateImagesRender'
 
 const FIXED_CANVAS_WIDTH = 395
 export default {
@@ -66,6 +72,7 @@ export default {
         bg_color:'#fff',
         visible:false,
         multi_goods_mode: '', // 多货号模式:''(默认单货号), 'single'(一个货号多角度), 'multiple'(多个货号同角度)
+        max_goods_count: null, // 多货号模式下,最多可追加多少个货号
       }
 
     }
@@ -224,6 +231,7 @@ export default {
       this.canvasForm.height = 1024
       this.canvasForm.bg_color = '#fff'
       this.canvasForm.multi_goods_mode = ''
+      this.canvasForm.max_goods_count = null
       this.canvasForm.visible = true;
     },
     handleAdjustCanvas() {
@@ -234,6 +242,7 @@ export default {
       this.canvasForm.height = this.this_canvas.height
       this.canvasForm.bg_color = this.this_canvas.bg_color
       this.canvasForm.multi_goods_mode = this.this_canvas.multi_goods_mode || ''
+      this.canvasForm.max_goods_count = this.this_canvas.max_goods_count || null
       this.canvasForm.visible = true;
     },
     submitCanvasInfo() {
@@ -250,7 +259,8 @@ export default {
                bg_color:this.canvasForm.bg_color,
                canvas_json:'',
                preview:'',
-               multi_goods_mode: this.canvasForm.multi_goods_mode || ''
+               multi_goods_mode: this.canvasForm.multi_goods_mode || '',
+               max_goods_count: this.canvasForm.multi_goods_mode ? (this.canvasForm.max_goods_count || null) : null,
         })
         const nextIndex = this.data.length - 1
         this.$emit('update:index',nextIndex)
@@ -318,6 +328,7 @@ export default {
         this.data[this.index].height = newHeight
         this.data[this.index].bg_color = this.canvasForm.bg_color
         this.data[this.index].multi_goods_mode = this.canvasForm.multi_goods_mode || ''
+        this.data[this.index].max_goods_count = this.canvasForm.multi_goods_mode ? (this.canvasForm.max_goods_count || null) : null
         this.canvasForm.visible = false;
       }
 
@@ -408,15 +419,44 @@ export default {
           name: item.name || '',
           tpl_url: item.tpl_url || '',
           image_path: item.image_path || '',
-          multi_goods_mode: item.multi_goods_mode || ''
+          multi_goods_mode: item.multi_goods_mode || '',
+          max_goods_count: item.max_goods_count || null,
         }
       }))
       this.$emit('save', payload)
       return payload;
     },
     async createImg(){
-      let canvas_json = JSON.parse(JSON.stringify(canvas))
-      let goodsData = JSON.parse(JSON.stringify(goods))
+       // 1. 组装数据源(后续可以从接口/其他页面传入)
+       const goodsData = [JSON.parse(JSON.stringify(goods)), JSON.parse(JSON.stringify(goods1))]
+       const canvasJson = JSON.parse(JSON.stringify(canvas))
+
+       // 2. 针对每个款号,生成所有画布图片 + 组合长图(在内存中生成 dataURL)
+       const bundles = await generateAllStyleImageBundles(canvasJson, goodsData)
+       if (!bundles || !bundles.length) {
+         ElMessage.warning('没有可生成的图片')
+         return { bundles: [] }
+       }
+
+       // 3. 如果在 Electron 客户端中,直接通过 IPC 把图片写入 EXE 同级的 output 目录
+       const clientStore = useClientStore()
+       if (clientStore?.ipc && clientStore.isClient) {
+         try {
+           const result = await clientStore.ipc.invoke(icpList.utils.saveGeneratedImages, { bundles })
+           if (result?.code === 0) {
+             ElMessage.success(`生成完成,已保存到 output 文件夹(共 ${result.data?.fileCount || 0} 张)`)
+           } else {
+             ElMessage.error(result?.msg || '保存生成图片失败')
+           }
+         } catch (e) {
+           console.error('[marketingEdit] saveGeneratedImages ipc error', e)
+           ElMessage.error('保存生成图片失败')
+         }
+       } else {
+         console.log('[marketingEdit] bundles (非客户端环境,仅返回数据)', bundles)
+       }
+
+       return { bundles }
     },
     saveCanvasSnapshot(targetIndex){
       const snapshotIndex = typeof targetIndex === 'number' ? targetIndex : this.index

+ 16 - 0
frontend/src/views/components/marketingEdit/tpl/header.js

@@ -78,6 +78,22 @@ export  default function tpl(){
                     <div v-else-if="canvasForm.multi_goods_mode === 'multiple'">画布中含有多个货号,画布中包含多个货号同一角度的图片</div>
                   </div>
                 </el-form-item>
+                <el-form-item
+                  v-if="canvasForm.multi_goods_mode === 'single' || canvasForm.multi_goods_mode === 'multiple'"
+                  label="最多货号数量"
+                >
+                  <el-input-number
+                    v-model="canvasForm.max_goods_count"
+                    :min="1"
+                    :max="99"
+                    :step="1"
+                    controls-position="right"
+                    placeholder="请输入最多可追加的货号数量"
+                  />
+                  <div style="font-size: 12px; color: #999; margin-top: 4px;">
+                    用于限制此画布在生成详情图时,最多会为多少个货号生成内容;为空则不限制。
+                  </div>
+                </el-form-item>
               </el-form>
               <div slot="footer" class="dialog-footer flex right">
                 <el-button @click="canvasForm.visible = false">取 消</el-button>

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