utils.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * CreateTemplate 模版设计页工具函数
  3. * 纯函数,无副作用,便于单元测试与复用
  4. */
  5. import { displayImageUrl } from '@/utils/displayImageUrl.js'
  6. /**
  7. * 将 material_url 转为可请求 URL(与接口返回一致,不拼接域名)
  8. * @param {string} path - 素材路径
  9. * @returns {string}
  10. */
  11. export function resolveMaterialUrl(path) {
  12. return displayImageUrl(path)
  13. }
  14. /** getImageLoadUrl 的别名,用于画布与预览图 */
  15. export function getImageLoadUrl(url) {
  16. return displayImageUrl(url)
  17. }
  18. /**
  19. * 画布导出用 URL 列表:
  20. * - 优先同源代理(VITE_PREVIEW_IMAGE_PROXY),由后端拉 OSS 再返回,浏览器 fetch 同源不依赖 OSS CORS。
  21. * - 其次直连 OSS(需 Bucket 配置 CORS 或 fetch 会失败)。
  22. * - HTTPS 页面将 http 图链升为 https,避免混合内容。
  23. */
  24. function resolveCanvasExportUrlCandidates(rawUrl) {
  25. const u = getImageLoadUrl(rawUrl.trim())
  26. if (!u || u.startsWith('data:')) return [u]
  27. let out = u
  28. if (out.startsWith('//')) out = 'https:' + out
  29. try {
  30. const parsed = new URL(out)
  31. if (
  32. typeof window !== 'undefined' &&
  33. window.isSecureContext &&
  34. window.location.protocol === 'https:' &&
  35. parsed.protocol === 'http:'
  36. ) {
  37. parsed.protocol = 'https:'
  38. out = parsed.toString()
  39. }
  40. } catch {
  41. /* ignore */
  42. }
  43. const list = []
  44. const push = (x) => {
  45. if (x && !list.includes(x)) list.push(x)
  46. }
  47. const proxy = typeof import.meta !== 'undefined' && import.meta.env?.VITE_PREVIEW_IMAGE_PROXY
  48. if (proxy && String(proxy).trim() && /^https?:\/\//i.test(out)) {
  49. const base = String(proxy).trim().replace(/\/$/, '')
  50. push(`${base}?url=${encodeURIComponent(out)}`)
  51. }
  52. push(out)
  53. return list
  54. }
  55. /**
  56. * 导出用 URL 加一次性查询参数,避免命中「仅展示用」的 img 缓存(无 CORS 头),否则后续 fetch/crossOrigin 会异常或 304 仍不可用。
  57. */
  58. function addExportCacheBust(url) {
  59. if (!url || url.startsWith('data:')) return url
  60. try {
  61. const u = new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
  62. u.searchParams.set('_exportcb', `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`)
  63. return u.toString()
  64. } catch {
  65. const sep = url.includes('?') ? '&' : '?'
  66. return `${url}${sep}_exportcb=${Date.now()}`
  67. }
  68. }
  69. async function blobToDrawable(blob) {
  70. if (!blob || !blob.size) return null
  71. if (typeof createImageBitmap !== 'undefined') {
  72. try {
  73. return await createImageBitmap(blob)
  74. } catch {
  75. /* 走 Image */
  76. }
  77. }
  78. const objUrl = URL.createObjectURL(blob)
  79. const img = new Image()
  80. try {
  81. await new Promise((resolve, reject) => {
  82. img.onload = () => resolve()
  83. img.onerror = () => reject(new Error('decode'))
  84. img.src = objUrl
  85. })
  86. } finally {
  87. URL.revokeObjectURL(objUrl)
  88. }
  89. return img.complete && img.naturalWidth ? img : null
  90. }
  91. /**
  92. * 离屏导出 previewImage(base64)时加载位图:须得到「未污染 canvas」的像素。
  93. * 1) fetch 直连 OSS:需 OSS 配置 CORS(允许管理端 Origin、GET)。
  94. * 2) 或配置 VITE_PREVIEW_IMAGE_PROXY 同源接口,由服务端拉 OSS,浏览器 fetch 同源 blob。
  95. * 3) fetch 失败后尝试 Image+crossOrigin(同样依赖 OSS CORS)。
  96. * 4) 最后普通 Image(会污染 canvas,上层 generateCanvasPreview 会回退占位图)。
  97. * @param {string} rawUrl - layer.url(含 OSS 完整地址或 data:)
  98. * @returns {Promise<ImageBitmap|HTMLImageElement|null>}
  99. */
  100. export async function loadImageForCanvasExport(rawUrl) {
  101. if (!rawUrl || typeof rawUrl !== 'string') return null
  102. const u = rawUrl.trim()
  103. if (!u) return null
  104. if (u.startsWith('data:')) {
  105. const img = new Image()
  106. await new Promise((resolve) => {
  107. img.onload = () => resolve()
  108. img.onerror = () => resolve()
  109. img.src = u
  110. })
  111. return img.complete && img.naturalWidth ? img : null
  112. }
  113. const candidates = resolveCanvasExportUrlCandidates(u)
  114. const fetchOpts = {
  115. mode: 'cors',
  116. credentials: 'omit',
  117. /** 禁止用「首次无 CORS 的展示请求」留下的缓存,否则易出现 304/内存缓存但无 ACAO,画布仍污染 */
  118. cache: 'no-store',
  119. referrerPolicy: 'no-referrer'
  120. }
  121. for (const url of candidates) {
  122. if (!url || url.startsWith('data:')) continue
  123. const busted = addExportCacheBust(url)
  124. try {
  125. const res = await fetch(busted, fetchOpts)
  126. if (res.ok) {
  127. const drawable = await blobToDrawable(await res.blob())
  128. if (drawable) return drawable
  129. }
  130. } catch {
  131. /* 下一候选 */
  132. }
  133. }
  134. for (const url of candidates) {
  135. if (!url || url.startsWith('data:')) continue
  136. const img = new Image()
  137. img.crossOrigin = 'anonymous'
  138. const busted = addExportCacheBust(url)
  139. await new Promise((resolve) => {
  140. img.onload = () => resolve()
  141. img.onerror = () => resolve()
  142. img.src = busted
  143. })
  144. if (img.complete && img.naturalWidth) return img
  145. }
  146. for (const url of candidates) {
  147. if (!url || url.startsWith('data:')) continue
  148. const img2 = new Image()
  149. const busted2 = addExportCacheBust(url)
  150. await new Promise((resolve) => {
  151. img2.onload = () => resolve()
  152. img2.onerror = () => resolve()
  153. img2.src = busted2
  154. })
  155. if (img2.complete && img2.naturalWidth) return img2
  156. }
  157. return null
  158. }
  159. /**
  160. * 若画布上已存在同 layer.id 的图片节点,且像素可导出(未污染 canvas),则复用,避免对 OSS 二次 fetch。
  161. * 仅在「预览图层」与当前 `.canvas` DOM 一致时有效(保存时预览层通常即当前页)。
  162. */
  163. export function tryGetCanvasDomDrawableForLayer(layerId, canvasRoot) {
  164. if (layerId == null || !canvasRoot || typeof canvasRoot.querySelector !== 'function') return null
  165. const el = canvasRoot.querySelector(`.layer[data-layer-id="${String(layerId)}"] img`)
  166. if (!el || el.tagName !== 'IMG' || !el.complete || !el.naturalWidth) return null
  167. try {
  168. const t = document.createElement('canvas')
  169. t.width = 1
  170. t.height = 1
  171. const c = t.getContext('2d')
  172. if (!c) return null
  173. c.drawImage(el, 0, 0, 1, 1)
  174. t.toDataURL()
  175. return el
  176. } catch {
  177. return null
  178. }
  179. }
  180. /**
  181. * 画布图层图片节点可选属性:设环境变量 VITE_CANVAS_IMG_CROSS_ORIGIN=1 且 OSS 已配置 CORS 后,
  182. * 与 tryGetCanvasDomDrawableForLayer / loadImageForCanvasExport 配合可导出完整 previewImage。
  183. */
  184. export function canvasLayerImgAttrs(layer) {
  185. const on = typeof import.meta !== 'undefined' && import.meta.env?.VITE_CANVAS_IMG_CROSS_ORIGIN
  186. if (on !== '1' && on !== 'true') return {}
  187. const u = layer?.url
  188. if (!u || typeof u !== 'string') return {}
  189. const x = getImageLoadUrl(u)
  190. if (!x || x.startsWith('data:') || x.startsWith('blob:')) return {}
  191. if (x.startsWith('http://') || x.startsWith('https://') || x.startsWith('//')) {
  192. return { crossorigin: 'anonymous' }
  193. }
  194. return {}
  195. }
  196. /**
  197. * 保存时:把 layer.url 转为仅相对路径(如 uploads/material/...),不含域名。
  198. * 支持 https/http 完整 OSS/CDN 地址,避免仅写了 http 导致 https 整条被误当时相对路径。
  199. * @param {string} url
  200. * @returns {string}
  201. */
  202. export function toStoragePath(url) {
  203. if (!url || typeof url !== 'string') return ''
  204. const u = url.trim()
  205. if (!u) return ''
  206. if (u.startsWith('data:')) return u
  207. if (u.startsWith('http://') || u.startsWith('https://')) {
  208. try {
  209. const parsed = new URL(u)
  210. let path = (parsed.pathname || '').replace(/^\/+/, '')
  211. path = path.replace(/^public\//, '')
  212. return path
  213. } catch {
  214. return u
  215. }
  216. }
  217. let path = u.replace(/^\/+/, '').replace(/^public\//, '')
  218. return path
  219. }
  220. /**
  221. * 调用后端接口时的路径:相对存储路径,如 uploads/merchant/...(不要前导 /;完整 OSS URL 会先经 toStoragePath 剥域名)
  222. * @param {string} url
  223. * @returns {string}
  224. */
  225. export function toApiStoragePath(url) {
  226. if (!url || typeof url !== 'string') return ''
  227. const u = url.trim()
  228. if (!u) return ''
  229. if (u.startsWith('data:') || u.startsWith('blob:')) return u
  230. const p = toStoragePath(u)
  231. if (!p) return ''
  232. if (p.startsWith('data:')) return p
  233. return p.replace(/^\/+/, '')
  234. }
  235. /**
  236. * 格式化文件大小
  237. * @param {number} bytes
  238. * @returns {string}
  239. */
  240. export function formatFileSize(bytes) {
  241. if (!bytes) return ''
  242. if (bytes < 1024) return `${bytes} B`
  243. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  244. return `${(bytes / 1024 / 1024).toFixed(2)} MB`
  245. }
  246. /**
  247. * 从扁平图层列表中收集素材库素材 id(可重复:同一素材占多图层则多次计入),用于上报使用次数。
  248. * 仅统计带 materialId / material_id 的图片类图层(来自素材库点击添加)。
  249. */
  250. export function collectMaterialIdsFromLayers(flatLayers) {
  251. const ids = []
  252. for (const l of flatLayers || []) {
  253. if ((l.type || 'image') === 'text') continue
  254. const raw = l.materialId ?? l.material_id
  255. if (raw == null || raw === '') continue
  256. const id = typeof raw === 'number' ? raw : Number(String(raw).trim())
  257. if (Number.isFinite(id) && id > 0) ids.push(id)
  258. }
  259. return ids
  260. }