| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- /**
- * 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<ImageBitmap|HTMLImageElement|null>}
- */
- 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
- }
|