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>} */ 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: 'jpeg', multiplier:2, 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, * 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: 'jpeg', multiplier:2, 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 }