/** * CreateTemplate 模版设计页工具函数 * 纯函数,无副作用,便于单元测试与复用 */ import { displayImageUrl } from '@/utils/displayImageUrl.js' /** * 将 material_url 转为可请求 URL(与接口返回一致,不拼接域名) * @param {string} path - 素材路径 * @returns {string} */ export function resolveMaterialUrl(path) { return displayImageUrl(path) } /** getImageLoadUrl 的别名,用于画布与预览图 */ export function getImageLoadUrl(url) { return displayImageUrl(url) } /** * 画布导出用 URL 列表: * - 优先同源代理(VITE_PREVIEW_IMAGE_PROXY),由后端拉 OSS 再返回,浏览器 fetch 同源不依赖 OSS CORS。 * - 其次直连 OSS(需 Bucket 配置 CORS 或 fetch 会失败)。 * - HTTPS 页面将 http 图链升为 https,避免混合内容。 */ function resolveCanvasExportUrlCandidates(rawUrl) { const u = getImageLoadUrl(rawUrl.trim()) if (!u || u.startsWith('data:')) return [u] let out = u if (out.startsWith('//')) out = 'https:' + out try { const parsed = new URL(out) if ( typeof window !== 'undefined' && window.isSecureContext && window.location.protocol === 'https:' && parsed.protocol === 'http:' ) { parsed.protocol = 'https:' out = parsed.toString() } } catch { /* ignore */ } const list = [] const push = (x) => { if (x && !list.includes(x)) list.push(x) } 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) return list } /** * 导出用 URL 加一次性查询参数,避免命中「仅展示用」的 img 缓存(无 CORS 头),否则后续 fetch/crossOrigin 会异常或 304 仍不可用。 */ function addExportCacheBust(url) { if (!url || url.startsWith('data:')) return url try { const u = new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/') u.searchParams.set('_exportcb', `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`) return u.toString() } catch { const sep = url.includes('?') ? '&' : '?' return `${url}${sep}_exportcb=${Date.now()}` } } async function blobToDrawable(blob) { if (!blob || !blob.size) return null if (typeof createImageBitmap !== 'undefined') { try { return await createImageBitmap(blob) } catch { /* 走 Image */ } } const objUrl = URL.createObjectURL(blob) const img = new Image() try { await new Promise((resolve, reject) => { img.onload = () => resolve() img.onerror = () => reject(new Error('decode')) img.src = objUrl }) } finally { URL.revokeObjectURL(objUrl) } return img.complete && img.naturalWidth ? img : null } /** * 离屏导出 previewImage(base64)时加载位图:须得到「未污染 canvas」的像素。 * 1) fetch 直连 OSS:需 OSS 配置 CORS(允许管理端 Origin、GET)。 * 2) 或配置 VITE_PREVIEW_IMAGE_PROXY 同源接口,由服务端拉 OSS,浏览器 fetch 同源 blob。 * 3) fetch 失败后尝试 Image+crossOrigin(同样依赖 OSS CORS)。 * 4) 最后普通 Image(会污染 canvas,上层 generateCanvasPreview 会回退占位图)。 * @param {string} rawUrl - layer.url(含 OSS 完整地址或 data:) * @returns {Promise} */ export async function loadImageForCanvasExport(rawUrl) { if (!rawUrl || typeof rawUrl !== 'string') return null const u = rawUrl.trim() if (!u) return null if (u.startsWith('data:')) { const img = new Image() await new Promise((resolve) => { img.onload = () => resolve() img.onerror = () => resolve() img.src = u }) return img.complete && img.naturalWidth ? img : null } const candidates = resolveCanvasExportUrlCandidates(u) const fetchOpts = { mode: 'cors', credentials: 'omit', /** 禁止用「首次无 CORS 的展示请求」留下的缓存,否则易出现 304/内存缓存但无 ACAO,画布仍污染 */ cache: 'no-store', referrerPolicy: 'no-referrer' } for (const url of candidates) { if (!url || url.startsWith('data:')) continue const busted = addExportCacheBust(url) try { const res = await fetch(busted, fetchOpts) if (res.ok) { const drawable = await blobToDrawable(await res.blob()) if (drawable) return drawable } } catch { /* 下一候选 */ } } for (const url of candidates) { if (!url || url.startsWith('data:')) continue 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 } 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 } return null } /** * 若画布上已存在同 layer.id 的图片节点,且像素可导出(未污染 canvas),则复用,避免对 OSS 二次 fetch。 * 仅在「预览图层」与当前 `.canvas` DOM 一致时有效(保存时预览层通常即当前页)。 */ export function tryGetCanvasDomDrawableForLayer(layerId, canvasRoot) { if (layerId == null || !canvasRoot || typeof canvasRoot.querySelector !== 'function') return null const el = canvasRoot.querySelector(`.layer[data-layer-id="${String(layerId)}"] img`) if (!el || el.tagName !== 'IMG' || !el.complete || !el.naturalWidth) return null try { const t = document.createElement('canvas') t.width = 1 t.height = 1 const c = t.getContext('2d') if (!c) return null c.drawImage(el, 0, 0, 1, 1) t.toDataURL() return el } catch { return null } } /** * 画布图层图片节点可选属性:设环境变量 VITE_CANVAS_IMG_CROSS_ORIGIN=1 且 OSS 已配置 CORS 后, * 与 tryGetCanvasDomDrawableForLayer / loadImageForCanvasExport 配合可导出完整 previewImage。 */ export function canvasLayerImgAttrs(layer) { const on = typeof import.meta !== 'undefined' && import.meta.env?.VITE_CANVAS_IMG_CROSS_ORIGIN if (on !== '1' && on !== 'true') return {} const u = layer?.url if (!u || typeof u !== 'string') return {} const x = getImageLoadUrl(u) if (!x || x.startsWith('data:') || x.startsWith('blob:')) return {} if (x.startsWith('http://') || x.startsWith('https://') || x.startsWith('//')) { return { crossorigin: 'anonymous' } } return {} } /** * 保存时:把 layer.url 转为仅相对路径(如 uploads/material/...),不含域名。 * 支持 https/http 完整 OSS/CDN 地址,避免仅写了 http 导致 https 整条被误当时相对路径。 * @param {string} url * @returns {string} */ export function toStoragePath(url) { if (!url || typeof url !== 'string') return '' const u = url.trim() if (!u) return '' if (u.startsWith('data:')) return u if (u.startsWith('http://') || u.startsWith('https://')) { try { const parsed = new URL(u) let path = (parsed.pathname || '').replace(/^\/+/, '') path = path.replace(/^public\//, '') return path } catch { return u } } let path = u.replace(/^\/+/, '').replace(/^public\//, '') return path } /** * 调用后端接口时的路径:相对存储路径,如 uploads/merchant/...(不要前导 /;完整 OSS URL 会先经 toStoragePath 剥域名) * @param {string} url * @returns {string} */ export function toApiStoragePath(url) { if (!url || typeof url !== 'string') return '' const u = url.trim() if (!u) return '' if (u.startsWith('data:') || u.startsWith('blob:')) return u const p = toStoragePath(u) if (!p) return '' if (p.startsWith('data:')) return p return p.replace(/^\/+/, '') } /** * 格式化文件大小 * @param {number} bytes * @returns {string} */ export function formatFileSize(bytes) { if (!bytes) return '' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / 1024 / 1024).toFixed(2)} MB` } /** * 从扁平图层列表中收集素材库素材 id(可重复:同一素材占多图层则多次计入),用于上报使用次数。 * 仅统计带 materialId / material_id 的图片类图层(来自素材库点击添加)。 */ export function collectMaterialIdsFromLayers(flatLayers) { const ids = [] for (const l of flatLayers || []) { if ((l.type || 'image') === 'text') continue const raw = l.materialId ?? l.material_id if (raw == null || raw === '') continue const id = typeof raw === 'number' ? raw : Number(String(raw).trim()) if (Number.isFinite(id) && id > 0) ids.push(id) } return ids }